From 21130f56810411a625a965e3fdf00655acaef31f Mon Sep 17 00:00:00 2001 From: Thibaut Lehmann Date: Fri, 2 Oct 2026 15:38:37 +0200 Subject: [PATCH] chore: import JavaScript SDK snapshot for production Import unchanged files from langsmith-javascript-staging at 1cad1168ce3ec5789b56c6a015096aa19d892339. Retain production initialization without importing staging commit history. --- .devcontainer/devcontainer.json | 15 + .github/ISSUE_TEMPLATE/bug-report.yml | 76 + .github/ISSUE_TEMPLATE/config.yml | 9 + .github/ISSUE_TEMPLATE/documentation.yml | 19 + .github/ISSUE_TEMPLATE/feature-request.yml | 21 + .github/ISSUE_TEMPLATE/other.yml | 18 + .github/dependabot.yml | 38 + .github/scripts/README.md | 33 + .github/scripts/format-mirror.sh | 21 + .github/scripts/mirror-from-langsmith-sdk.sh | 75 + .github/scripts/test-format-mirror.sh | 53 + .github/sdk-reconciliation.md | 60 + .github/workflows/auto-label-issues.yml | 24 + .github/workflows/check-pr-source.yml | 36 + .../check-stainless-file-modification.yml | 74 + .github/workflows/ci.yml | 348 + .../workflows/mirror-from-langsmith-sdk.yml | 216 + .github/workflows/perf.yml | 209 + .github/workflows/release-doctor.yml | 21 + .github/workflows/release-please.yml | 20 + .../stlc-promote-from-prod-to-main.yml | 47 + .../workflows/stlc-promote-to-production.yml | 91 + .github/workflows/stlc-seal-custom-code.yml | 47 + .../workflows/stlc-sync-from-production.yml | 86 + .gitignore | 15 + .mirror-source-sha | 1 + .prettierignore | 17 + .prettierrc.json | 17 + .release-please-manifest.json | 3 + .stats.yml | 1 + AGENTS.md | 58 + Brewfile | 1 + CONTRIBUTING.md | 124 + LICENSE | 7 + README.md | 1068 ++ SECURITY.md | 23 + api.md | 342 + bin/check-release-environment | 22 + bin/publish-npm | 61 + eslint.config.mjs | 49 + examples/.keep | 4 + internal/environment_tests/docker-compose.yml | 79 + .../test-deepagent-compat/package.json | 23 + .../test-deepagent-compat/pnpm-lock.yaml | 911 + .../test-deepagent-compat/pnpm-workspace.yaml | 3 + .../test-deepagent-compat/src/index.ts | 8 + .../test-exports-cf/package.json | 23 + .../test-exports-cf/pnpm-lock.yaml | 1464 ++ .../test-exports-cf/pnpm-workspace.yaml | 3 + .../test-exports-cf/src/index.ts | 44 + .../test-exports-cf/wrangler.toml | 3 + .../test-exports-cjs/package.json | 12 + .../test-exports-cjs/pnpm-lock.yaml | 69 + .../test-exports-cjs/pnpm-workspace.yaml | 3 + .../test-exports-cjs/src/index.js | 46 + .../test-exports-esbuild/package.json | 16 + .../test-exports-esbuild/pnpm-lock.yaml | 341 + .../test-exports-esbuild/pnpm-workspace.yaml | 3 + .../test-exports-esbuild/src/index.ts | 34 + .../test-exports-esbuild/tsconfig.json | 3 + .../test-exports-esm/package.json | 18 + .../test-exports-esm/pnpm-lock.yaml | 385 + .../test-exports-esm/pnpm-workspace.yaml | 3 + .../test-exports-esm/src/index.ts | 46 + .../test-exports-esm/src/typecheck.ts | 3 + .../test-exports-esm/tsconfig.json | 13 + .../test-exports-metro/babel.config.js | 3 + .../test-exports-metro/package.json | 16 + .../test-exports-metro/pnpm-lock.yaml | 2678 +++ .../test-exports-metro/pnpm-workspace.yaml | 4 + .../test-exports-metro/src/index.js | 34 + .../test-exports-vite/package.json | 16 + .../test-exports-vite/pnpm-lock.yaml | 1060 ++ .../test-exports-vite/pnpm-workspace.yaml | 3 + .../test-exports-vite/src/index.ts | 37 + .../test-exports-vite/vite.config.ts | 16 + .../test-exports-webpack/package.json | 21 + .../test-exports-webpack/pnpm-lock.yaml | 1532 ++ .../test-exports-webpack/pnpm-workspace.yaml | 3 + .../test-exports-webpack/src/index.js | 34 + .../test-exports-webpack/tsconfig.json | 3 + .../test-exports-webpack/webpack.config.js | 19 + jest.config.ts | 66 + jest.setup.cjs | 1 + ls.vitest.config.ts | 10 + package.json | 221 + pnpm-lock.yaml | 14585 ++++++++++++++++ pnpm-workspace.yaml | 13 + release-please-config.json | 64 + scripts/bootstrap | 26 + scripts/build | 55 + scripts/fast-format | 37 + scripts/format | 11 + scripts/lint | 25 + scripts/test | 15 + scripts/typedoc-plugin.cjs | 102 + scripts/utils/attw-report.cjs | 26 + scripts/utils/check-is-in-git-install.sh | 9 + scripts/utils/check-version.cjs | 20 + scripts/utils/fix-index-exports.cjs | 17 + scripts/utils/git-swap.sh | 13 + scripts/utils/make-dist-package-json.cjs | 30 + scripts/utils/postprocess-files.cjs | 108 + scripts/utils/upload-artifact.sh | 27 + src/anonymizer.ts | 2 + src/api-promise.ts | 2 + src/client.ts | 1171 ++ src/core/README.md | 3 + src/core/api-promise.ts | 92 + src/core/error.ts | 130 + src/core/pagination.ts | 548 + src/core/resource.ts | 11 + src/core/uploads.ts | 2 + src/error.ts | 2 + src/evaluation.ts | 2 + src/experimental/anthropic.ts | 2 + src/experimental/otel/exporter.ts | 2 + src/experimental/otel/processor.ts | 2 + src/experimental/otel/setup.ts | 2 + src/experimental/sandbox.ts | 2 + src/experimental/vercel.ts | 2 + src/index.ts | 26 + src/internal/README.md | 3 + src/internal/builtin-types.ts | 93 + src/internal/detect-platform.ts | 196 + src/internal/errors.ts | 33 + src/internal/headers.ts | 97 + src/internal/parse.ts | 56 + src/internal/qs/LICENSE.md | 13 + src/internal/qs/README.md | 3 + src/internal/qs/formats.ts | 10 + src/internal/qs/index.ts | 13 + src/internal/qs/stringify.ts | 385 + src/internal/qs/types.ts | 71 + src/internal/qs/utils.ts | 265 + src/internal/request-options.ts | 91 + src/internal/shim-types.ts | 26 + src/internal/shims.ts | 107 + src/internal/to-file.ts | 154 + src/internal/types.ts | 93 + src/internal/uploads.ts | 187 + src/internal/utils.ts | 9 + src/internal/utils/base64.ts | 40 + src/internal/utils/bytes.ts | 32 + src/internal/utils/env.ts | 18 + src/internal/utils/log.ts | 129 + src/internal/utils/path.ts | 88 + src/internal/utils/query.ts | 7 + src/internal/utils/sleep.ts | 3 + src/internal/utils/uuid.ts | 17 + src/internal/utils/values.ts | 105 + src/jest.ts | 2 + src/jest/reporter.ts | 3 + src/langchain.ts | 2 + src/lib/.keep | 4 + src/lib/anonymizer/index.ts | 326 + src/lib/client.ts | 8069 +++++++++ src/lib/env.ts | 24 + src/lib/evaluation/_random_name.ts | 728 + src/lib/evaluation/_runner.ts | 1555 ++ src/lib/evaluation/evaluate_comparative.ts | 431 + src/lib/evaluation/evaluator.ts | 275 + src/lib/evaluation/index.ts | 17 + src/lib/evaluation/string_evaluator.ts | 69 + src/lib/experimental/anthropic/context.ts | 656 + src/lib/experimental/anthropic/index.ts | 391 + src/lib/experimental/anthropic/messages.ts | 205 + src/lib/experimental/anthropic/transcripts.ts | 188 + src/lib/experimental/anthropic/types.ts | 65 + src/lib/experimental/anthropic/usage.ts | 253 + src/lib/experimental/anthropic/utils.ts | 28 + src/lib/experimental/otel/constants.ts | 71 + src/lib/experimental/otel/exporter.ts | 249 + src/lib/experimental/otel/processor.ts | 111 + src/lib/experimental/otel/setup.ts | 150 + src/lib/experimental/otel/translator.ts | 622 + src/lib/experimental/otel/types.ts | 36 + src/lib/experimental/otel/utils.ts | 29 + src/lib/experimental/vercel/_agent_type.ts | 34 + src/lib/experimental/vercel/index.ts | 3 + src/lib/experimental/vercel/middleware.ts | 316 + src/lib/experimental/vercel/telemetry.ts | 848 + src/lib/experimental/vercel/utils.ts | 384 + src/lib/experimental/vercel/wrap.ts | 1068 ++ src/lib/index.ts | 59 + src/lib/jest/index.ts | 504 + src/lib/jest/reporter.ts | 42 + src/lib/langchain.ts | 173 + src/lib/run_trees.ts | 1308 ++ src/lib/sandbox/README.md | 985 ++ src/lib/sandbox/access_delegation.ts | 54 + src/lib/sandbox/client.ts | 1440 ++ src/lib/sandbox/command_handle.ts | 364 + src/lib/sandbox/errors.ts | 368 + src/lib/sandbox/helpers.ts | 506 + src/lib/sandbox/index.ts | 161 + src/lib/sandbox/mounts.ts | 408 + src/lib/sandbox/proxy_config.ts | 249 + src/lib/sandbox/sandbox.ts | 769 + src/lib/sandbox/service_url.ts | 105 + src/lib/sandbox/types.ts | 1005 ++ src/lib/sandbox/verify.ts | 407 + src/lib/sandbox/ws_execute.ts | 585 + src/lib/schemas.ts | 814 + src/lib/singletons/constants.ts | 18 + src/lib/singletons/fetch.ts | 66 + src/lib/singletons/otel.ts | 201 + src/lib/singletons/traceable.ts | 93 + src/lib/singletons/types.ts | 86 + src/lib/tests/anonymizer.test.ts | 596 + src/lib/tests/anthropic_usage.test.ts | 98 + src/lib/tests/batch_client.int.test.ts | 294 + src/lib/tests/batch_client.test.ts | 2422 +++ src/lib/tests/bun/traceable.test.bun.ts | 31 + src/lib/tests/claude_agent_sdk.int.test.ts | 898 + src/lib/tests/claude_agent_sdk.test.ts | 3035 ++++ src/lib/tests/client.int.test.ts | 2588 +++ src/lib/tests/client.test.ts | 2599 +++ src/lib/tests/client_headers.test.ts | 362 + src/lib/tests/client_retry.test.ts | 162 + src/lib/tests/construct_url.test.ts | 53 + src/lib/tests/context.int.test.ts | 237 + src/lib/tests/context.test.ts | 539 + src/lib/tests/error.test.ts | 62 + src/lib/tests/evaluate.int.test.ts | 725 + .../tests/evaluate_attachments.int.test.ts | 468 + src/lib/tests/evaluate_runner.test.ts | 1078 ++ src/lib/tests/experiment_manager.int.test.ts | 45 + src/lib/tests/failed_traces.test.ts | 233 + src/lib/tests/fetch.test.ts | 125 + src/lib/tests/git_info.test.ts | 134 + src/lib/tests/guard.vitesttest.ts | 92 + .../jestlike/experiment_metadata.test.ts | 136 + src/lib/tests/jestlike/globals.test.ts | 50 + src/lib/tests/jestlike/jest.int.test.ts | 27 + src/lib/tests/jestlike/jest.test.ts | 293 + .../jest_existing_dataset.int.test.ts | 61 + src/lib/tests/jestlike/vitest.vitesteval.ts | 131 + .../vitest_separate_file.vitesteval.ts | 90 + src/lib/tests/jestlike/wrap_jest.test.ts | 28 + .../tests/jestlike/wrap_vitest.vitesteval.ts | 45 + src/lib/tests/lcls_handoff.int.test.ts | 98 + src/lib/tests/ls_agent_type.test.ts | 67 + src/lib/tests/node_fetch.int.test.ts | 43 + src/lib/tests/openai_agents_sdk.int.test.ts | 270 + src/lib/tests/openai_agents_sdk.test.ts | 1382 ++ src/lib/tests/otel_translator.vitesttest.ts | 495 + src/lib/tests/perf.int.test.ts | 393 + src/lib/tests/profile-lock.test.ts | 136 + src/lib/tests/profile-oauth-discovery.test.ts | 180 + src/lib/tests/prompt_cache.test.ts | 837 + src/lib/tests/public_runs.int.test.ts | 124 + src/lib/tests/replica_endpoints.test.ts | 923 + src/lib/tests/run_stats.test.ts | 18 + src/lib/tests/run_trees.int.test.ts | 269 + src/lib/tests/run_trees.test.ts | 944 + src/lib/tests/run_trees.uuid.test.ts | 278 + src/lib/tests/runs_share.int.test.ts | 98 + src/lib/tests/sandbox.test.ts | 2735 +++ .../tests/sandbox_access_delegation.test.ts | 109 + src/lib/tests/sandbox_aws_role_auth.test.ts | 319 + .../tests/sandbox_command_id_retry.test.ts | 368 + src/lib/tests/sandbox_error_handling.test.ts | 126 + src/lib/tests/sandbox_reconnect_ack.test.ts | 207 + .../sandbox_run_config_and_files.test.ts | 437 + src/lib/tests/sandbox_service_url.test.ts | 179 + src/lib/tests/sandbox_verify.test.ts | 370 + src/lib/tests/sandbox_ws_handshake.test.ts | 94 + src/lib/tests/serialize.test.ts | 585 + src/lib/tests/serialize_worker.test.ts | 124 + .../anthropic_managed_builtin_tools.jsonl | 15 + .../anthropic_managed_custom_tools.jsonl | 19 + .../anthropic_managed_mcp_tools.jsonl | 22 + .../anthropic_managed_mcp_tools_reject.jsonl | 27 + src/lib/tests/test_data/beemovie.txt | 1363 ++ .../langsmith_js_wrap_openai_default.json | 98 + .../langsmith_js_wrap_openai_reasoning.json | 97 + .../langsmith_js_wrap_openai_stream.json | 109 + ...gsmith_js_wrap_openai_stream_no usage.json | 92 + src/lib/tests/test_data/parrot-icon.png | Bin 0 -> 35267 bytes src/lib/tests/threads.int.test.ts | 130 + src/lib/tests/traceable.test.ts | 3565 ++++ src/lib/tests/traceable_langchain.test.ts | 624 + src/lib/tests/traces.int.test.ts | 98 + src/lib/tests/tracing_mode.test.ts | 58 + src/lib/tests/utils.ts | 571 + src/lib/tests/utils/markers.ts | 64 + src/lib/tests/utils/mock_client.ts | 33 + src/lib/tests/utils/tree.ts | 126 + src/lib/tests/utils/vitest_mock_client.ts | 33 + src/lib/tests/v2_migration.test.ts | 62 + .../vercel/telemetry/anthropic.int.test.ts | 590 + .../telemetry/harness-agent.int.test.ts | 731 + .../telemetry/harness-claude-code.int.test.ts | 223 + .../vercel/telemetry/methods.vitesttest.ts | 1783 ++ .../tests/vercel/telemetry/openai.int.test.ts | 991 ++ src/lib/tests/vercel/utils.ts | 117 + .../vercel/wrapper/methods.vitesttest.ts | 3053 ++++ .../tests/vercel/wrapper/openai.int.test.ts | 734 + .../wrapper/sdk5-backcompat.vitesttest.ts | 1504 ++ src/lib/tests/vercel_agent_type.test.ts | 100 + src/lib/tests/vitest_reporter.vitesttest.ts | 70 + src/lib/tests/wrapped_anthropic.int.test.ts | 2100 +++ ...ped_anthropic_managed_agents.vitesttest.ts | 1287 ++ src/lib/tests/wrapped_anthropic_mcp.test.ts | 206 + src/lib/tests/wrapped_gemini.int.test.ts | 487 + src/lib/tests/wrapped_gemini.vitesttest.ts | 105 + src/lib/tests/wrapped_openai.int.test.ts | 1190 ++ src/lib/tests/wrapped_openai.test.ts | 248 + src/lib/traceable.ts | 1300 ++ src/lib/utils/_git.ts | 135 + src/lib/utils/_uuid.ts | 195 + src/lib/utils/asserts.ts | 50 + src/lib/utils/async_caller.ts | 206 + src/lib/utils/atee.ts | 24 + src/lib/utils/chalk/license | 9 + src/lib/utils/chalk/source/index.ts | 227 + src/lib/utils/chalk/source/utilities.ts | 35 + .../chalk/source/vendor/ansi-styles/index.ts | 225 + .../source/vendor/supports-color/browser.ts | 36 + .../source/vendor/supports-color/index.ts | 192 + src/lib/utils/console-table-printer/LICENSE | 21 + src/lib/utils/console-table-printer/index.ts | 9 + .../src/console-table-printer.ts | 47 + .../src/internalTable/input-converter.ts | 42 + .../internalTable/internal-table-printer.ts | 268 + .../src/internalTable/internal-table.ts | 180 + .../src/internalTable/table-pre-processors.ts | 70 + .../src/models/common.ts | 17 + .../src/models/external-table.ts | 110 + .../src/models/internal-table.ts | 33 + .../src/utils/colored-console-line.ts | 40 + .../src/utils/console-utils.ts | 25 + .../src/utils/string-utils.ts | 84 + .../src/utils/table-constants.ts | 61 + .../src/utils/table-helpers.ts | 184 + src/lib/utils/context_vars.ts | 34 + src/lib/utils/env.ts | 298 + src/lib/utils/error.ts | 205 + src/lib/utils/fast-safe-stringify/LICENSE | 23 + src/lib/utils/fast-safe-stringify/index.ts | 556 + src/lib/utils/fs.browser.ts | 66 + src/lib/utils/fs.ts | 94 + src/lib/utils/guard.ts | 11 + src/lib/utils/is-network-error/index.ts | 50 + src/lib/utils/is-network-error/license | 9 + src/lib/utils/jestlike/constants.ts | 8 + src/lib/utils/jestlike/globals.ts | 84 + src/lib/utils/jestlike/index.ts | 867 + src/lib/utils/jestlike/matchers.ts | 160 + src/lib/utils/jestlike/reporter.ts | 293 + src/lib/utils/jestlike/types.ts | 60 + src/lib/utils/jestlike/vendor/LICENSE | 21 + src/lib/utils/jestlike/vendor/chain.ts | 124 + src/lib/utils/jestlike/vendor/evaluatedBy.ts | 129 + src/lib/utils/ls_agent_type.ts | 38 + src/lib/utils/messages.ts | 32 + src/lib/utils/p-queue.ts | 8 + src/lib/utils/p-retry/index.ts | 267 + src/lib/utils/p-retry/license | 9 + src/lib/utils/profile-lock.ts | 125 + src/lib/utils/profiles.ts | 512 + src/lib/utils/project.ts | 12 + src/lib/utils/prompt_cache/index.ts | 407 + src/lib/utils/prompts.ts | 36 + src/lib/utils/redaction.ts | 49 + src/lib/utils/sampling.ts | 38 + src/lib/utils/serialize_worker.ts | 374 + src/lib/utils/shuffle.ts | 13 + src/lib/utils/simple-wcswidth/LICENSE | 21 + src/lib/utils/simple-wcswidth/index.ts | 4 + .../simple-wcswidth/src/binary-search.ts | 29 + src/lib/utils/simple-wcswidth/src/models.ts | 4 + .../simple-wcswidth/src/non-spacing-chars.ts | 150 + src/lib/utils/simple-wcswidth/src/wcswidth.ts | 19 + src/lib/utils/simple-wcswidth/src/wcwidth.ts | 72 + src/lib/utils/types.ts | 13 + src/lib/utils/url.ts | 28 + src/lib/utils/usage.ts | 32 + src/lib/utils/uuid/LICENSE.md | 9 + src/lib/utils/uuid/src/index.ts | 10 + src/lib/utils/uuid/src/max.ts | 1 + src/lib/utils/uuid/src/nil.ts | 1 + src/lib/utils/uuid/src/parse.ts | 39 + src/lib/utils/uuid/src/regex.ts | 1 + src/lib/utils/uuid/src/rng.ts | 7 + src/lib/utils/uuid/src/sha1.ts | 110 + src/lib/utils/uuid/src/stringify.ts | 58 + src/lib/utils/uuid/src/types.ts | 28 + src/lib/utils/uuid/src/v35.ts | 74 + src/lib/utils/uuid/src/v4.ts | 65 + src/lib/utils/uuid/src/v5.ts | 31 + src/lib/utils/uuid/src/v7.ts | 135 + src/lib/utils/uuid/src/validate.ts | 7 + src/lib/utils/uuid/src/version.ts | 11 + src/lib/utils/v2_migration.ts | 85 + src/lib/utils/vercel.ts | 402 + src/lib/utils/warn.ts | 37 + src/lib/utils/worker_threads.browser.ts | 17 + src/lib/utils/worker_threads.ts | 17 + src/lib/utils/xxhash/LICENSE | 25 + src/lib/utils/xxhash/xxhash.ts | 502 + src/lib/uuid.ts | 38 + src/lib/vitest/index.mts | 347 + src/lib/vitest/index.ts | 345 + src/lib/vitest/reporter.mts | 35 + src/lib/vitest/reporter.ts | 32 + src/lib/vitest/utils/esm.mts | 43 + src/lib/vitest/utils/reporter.ts | 56 + src/lib/vitest/utils/wrapper.ts | 146 + src/lib/wrappers/anthropic.ts | 576 + src/lib/wrappers/gemini.ts | 517 + src/lib/wrappers/gemini.utils.ts | 73 + src/lib/wrappers/generic.ts | 71 + src/lib/wrappers/index.ts | 2 + src/lib/wrappers/openai.ts | 691 + src/lib/wrappers/openai_agents.ts | 976 ++ src/lib/wrappers/utils/anthropic_managed.ts | 898 + src/pagination.ts | 2 + src/resource.ts | 2 + src/resources.ts | 1 + src/resources/annotation-queues.ts | 3 + .../annotation-queues/annotation-queues.ts | 694 + src/resources/annotation-queues/index.ts | 60 + src/resources/annotation-queues/items.ts | 396 + src/resources/annotation-queues/runs.ts | 313 + src/resources/datasets.ts | 3 + src/resources/datasets/datasets.ts | 227 + src/resources/datasets/examples.ts | 36 + src/resources/datasets/experiment-runs.ts | 153 + src/resources/datasets/index.ts | 19 + src/resources/fleet.ts | 3 + src/resources/fleet/fleet.ts | 15 + src/resources/fleet/index.ts | 4 + src/resources/fleet/threads.ts | 29 + src/resources/index.ts | 137 + src/resources/info.ts | 76 + src/resources/issues.ts | 466 + src/resources/online-evaluators.ts | 587 + src/resources/product-feedback.ts | 129 + src/resources/public.ts | 3 + src/resources/public/index.ts | 4 + src/resources/public/public.ts | 20 + src/resources/public/runs.ts | 147 + src/resources/runs.ts | 3 + src/resources/runs/index.ts | 20 + src/resources/runs/runs.ts | 1097 ++ src/resources/runs/share.ts | 77 + src/resources/sandboxes.ts | 3 + src/resources/sandboxes/boxes.ts | 1290 ++ src/resources/sandboxes/index.ts | 44 + src/resources/sandboxes/registries.ts | 135 + src/resources/sandboxes/sandboxes.ts | 949 + src/resources/sandboxes/snapshots.ts | 196 + src/resources/shared.ts | 15 + src/resources/threads.ts | 3 + src/resources/threads/index.ts | 23 + src/resources/threads/share.ts | 109 + src/resources/threads/threads.ts | 1209 ++ src/resources/traces.ts | 270 + src/run_trees.ts | 2 + src/sandbox.ts | 2 + src/schemas.ts | 2 + src/singletons/traceable.ts | 2 + src/traceable.ts | 2 + src/uploads.ts | 2 + src/utils/jestlike.ts | 2 + src/version.ts | 1 + src/vitest.mts | 2 + src/vitest/reporter.mts | 2 + src/wrappers.ts | 2 + src/wrappers/anthropic.ts | 2 + src/wrappers/gemini.ts | 3 + src/wrappers/openai.ts | 2 + src/wrappers/openai_agents.ts | 2 + .../annotation-queues.test.ts | 271 + .../annotation-queues/items.test.ts | 137 + .../annotation-queues/runs.test.ts | 157 + tests/api-resources/datasets/examples.test.ts | 32 + .../datasets/experiment-runs.test.ts | 23 + tests/api-resources/fleet/threads.test.ts | 23 + tests/api-resources/info.test.ts | 23 + tests/api-resources/issues.test.ts | 68 + tests/api-resources/online-evaluators.test.ts | 152 + tests/api-resources/product-feedback.test.ts | 54 + tests/api-resources/public/runs.test.ts | 49 + tests/api-resources/runs/runs.test.ts | 71 + tests/api-resources/runs/share.test.ts | 35 + tests/api-resources/sandboxes/boxes.test.ts | 243 + .../sandboxes/registries.test.ts | 98 + .../api-resources/sandboxes/sandboxes.test.ts | 39 + .../api-resources/sandboxes/snapshots.test.ts | 116 + tests/api-resources/threads/share.test.ts | 74 + tests/api-resources/threads/threads.test.ts | 103 + tests/api-resources/traces.test.ts | 49 + tests/base64.test.ts | 80 + tests/buildHeaders.test.ts | 88 + tests/form.test.ts | 85 + tests/index.test.ts | 826 + tests/path.test.ts | 462 + tests/qs/empty-keys-cases.ts | 271 + tests/qs/stringify.test.ts | 2232 +++ tests/qs/utils.test.ts | 169 + tests/runs-aliases.test.ts | 20 + tests/stringifyQuery.test.ts | 21 + tests/uploads.test.ts | 106 + tsc-multi.json | 15 + tsc-multi.vitest.json | 9 + tsconfig.build.json | 18 + tsconfig.build.vitest.json | 5 + tsconfig.deno.json | 15 + tsconfig.dist-src.json | 11 + tsconfig.esm.json | 8 + tsconfig.json | 36 + typedoc.json | 44 + vitest.config.ts | 9 + 516 files changed, 144061 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/documentation.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/other.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/README.md create mode 100755 .github/scripts/format-mirror.sh create mode 100755 .github/scripts/mirror-from-langsmith-sdk.sh create mode 100755 .github/scripts/test-format-mirror.sh create mode 100644 .github/sdk-reconciliation.md create mode 100644 .github/workflows/auto-label-issues.yml create mode 100644 .github/workflows/check-pr-source.yml create mode 100644 .github/workflows/check-stainless-file-modification.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/mirror-from-langsmith-sdk.yml create mode 100644 .github/workflows/perf.yml create mode 100644 .github/workflows/release-doctor.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .github/workflows/stlc-promote-from-prod-to-main.yml create mode 100644 .github/workflows/stlc-promote-to-production.yml create mode 100644 .github/workflows/stlc-seal-custom-code.yml create mode 100644 .github/workflows/stlc-sync-from-production.yml create mode 100644 .gitignore create mode 100644 .mirror-source-sha create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 .release-please-manifest.json create mode 100644 .stats.yml create mode 100644 AGENTS.md create mode 100644 Brewfile create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 api.md create mode 100644 bin/check-release-environment create mode 100644 bin/publish-npm create mode 100644 eslint.config.mjs create mode 100644 examples/.keep create mode 100644 internal/environment_tests/docker-compose.yml create mode 100644 internal/environment_tests/test-deepagent-compat/package.json create mode 100644 internal/environment_tests/test-deepagent-compat/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-deepagent-compat/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-deepagent-compat/src/index.ts create mode 100644 internal/environment_tests/test-exports-cf/package.json create mode 100644 internal/environment_tests/test-exports-cf/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-cf/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-cf/src/index.ts create mode 100644 internal/environment_tests/test-exports-cf/wrangler.toml create mode 100644 internal/environment_tests/test-exports-cjs/package.json create mode 100644 internal/environment_tests/test-exports-cjs/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-cjs/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-cjs/src/index.js create mode 100644 internal/environment_tests/test-exports-esbuild/package.json create mode 100644 internal/environment_tests/test-exports-esbuild/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-esbuild/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-esbuild/src/index.ts create mode 100644 internal/environment_tests/test-exports-esbuild/tsconfig.json create mode 100644 internal/environment_tests/test-exports-esm/package.json create mode 100644 internal/environment_tests/test-exports-esm/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-esm/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-esm/src/index.ts create mode 100644 internal/environment_tests/test-exports-esm/src/typecheck.ts create mode 100644 internal/environment_tests/test-exports-esm/tsconfig.json create mode 100644 internal/environment_tests/test-exports-metro/babel.config.js create mode 100644 internal/environment_tests/test-exports-metro/package.json create mode 100644 internal/environment_tests/test-exports-metro/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-metro/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-metro/src/index.js create mode 100644 internal/environment_tests/test-exports-vite/package.json create mode 100644 internal/environment_tests/test-exports-vite/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-vite/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-vite/src/index.ts create mode 100644 internal/environment_tests/test-exports-vite/vite.config.ts create mode 100644 internal/environment_tests/test-exports-webpack/package.json create mode 100644 internal/environment_tests/test-exports-webpack/pnpm-lock.yaml create mode 100644 internal/environment_tests/test-exports-webpack/pnpm-workspace.yaml create mode 100644 internal/environment_tests/test-exports-webpack/src/index.js create mode 100644 internal/environment_tests/test-exports-webpack/tsconfig.json create mode 100644 internal/environment_tests/test-exports-webpack/webpack.config.js create mode 100644 jest.config.ts create mode 100644 jest.setup.cjs create mode 100644 ls.vitest.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 release-please-config.json create mode 100755 scripts/bootstrap create mode 100755 scripts/build create mode 100755 scripts/fast-format create mode 100755 scripts/format create mode 100755 scripts/lint create mode 100755 scripts/test create mode 100644 scripts/typedoc-plugin.cjs create mode 100644 scripts/utils/attw-report.cjs create mode 100755 scripts/utils/check-is-in-git-install.sh create mode 100644 scripts/utils/check-version.cjs create mode 100644 scripts/utils/fix-index-exports.cjs create mode 100755 scripts/utils/git-swap.sh create mode 100644 scripts/utils/make-dist-package-json.cjs create mode 100644 scripts/utils/postprocess-files.cjs create mode 100755 scripts/utils/upload-artifact.sh create mode 100644 src/anonymizer.ts create mode 100644 src/api-promise.ts create mode 100644 src/client.ts create mode 100644 src/core/README.md create mode 100644 src/core/api-promise.ts create mode 100644 src/core/error.ts create mode 100644 src/core/pagination.ts create mode 100644 src/core/resource.ts create mode 100644 src/core/uploads.ts create mode 100644 src/error.ts create mode 100644 src/evaluation.ts create mode 100644 src/experimental/anthropic.ts create mode 100644 src/experimental/otel/exporter.ts create mode 100644 src/experimental/otel/processor.ts create mode 100644 src/experimental/otel/setup.ts create mode 100644 src/experimental/sandbox.ts create mode 100644 src/experimental/vercel.ts create mode 100644 src/index.ts create mode 100644 src/internal/README.md create mode 100644 src/internal/builtin-types.ts create mode 100644 src/internal/detect-platform.ts create mode 100644 src/internal/errors.ts create mode 100644 src/internal/headers.ts create mode 100644 src/internal/parse.ts create mode 100644 src/internal/qs/LICENSE.md create mode 100644 src/internal/qs/README.md create mode 100644 src/internal/qs/formats.ts create mode 100644 src/internal/qs/index.ts create mode 100644 src/internal/qs/stringify.ts create mode 100644 src/internal/qs/types.ts create mode 100644 src/internal/qs/utils.ts create mode 100644 src/internal/request-options.ts create mode 100644 src/internal/shim-types.ts create mode 100644 src/internal/shims.ts create mode 100644 src/internal/to-file.ts create mode 100644 src/internal/types.ts create mode 100644 src/internal/uploads.ts create mode 100644 src/internal/utils.ts create mode 100644 src/internal/utils/base64.ts create mode 100644 src/internal/utils/bytes.ts create mode 100644 src/internal/utils/env.ts create mode 100644 src/internal/utils/log.ts create mode 100644 src/internal/utils/path.ts create mode 100644 src/internal/utils/query.ts create mode 100644 src/internal/utils/sleep.ts create mode 100644 src/internal/utils/uuid.ts create mode 100644 src/internal/utils/values.ts create mode 100644 src/jest.ts create mode 100644 src/jest/reporter.ts create mode 100644 src/langchain.ts create mode 100644 src/lib/.keep create mode 100644 src/lib/anonymizer/index.ts create mode 100644 src/lib/client.ts create mode 100644 src/lib/env.ts create mode 100644 src/lib/evaluation/_random_name.ts create mode 100644 src/lib/evaluation/_runner.ts create mode 100644 src/lib/evaluation/evaluate_comparative.ts create mode 100644 src/lib/evaluation/evaluator.ts create mode 100644 src/lib/evaluation/index.ts create mode 100644 src/lib/evaluation/string_evaluator.ts create mode 100644 src/lib/experimental/anthropic/context.ts create mode 100644 src/lib/experimental/anthropic/index.ts create mode 100644 src/lib/experimental/anthropic/messages.ts create mode 100644 src/lib/experimental/anthropic/transcripts.ts create mode 100644 src/lib/experimental/anthropic/types.ts create mode 100644 src/lib/experimental/anthropic/usage.ts create mode 100644 src/lib/experimental/anthropic/utils.ts create mode 100644 src/lib/experimental/otel/constants.ts create mode 100644 src/lib/experimental/otel/exporter.ts create mode 100644 src/lib/experimental/otel/processor.ts create mode 100644 src/lib/experimental/otel/setup.ts create mode 100644 src/lib/experimental/otel/translator.ts create mode 100644 src/lib/experimental/otel/types.ts create mode 100644 src/lib/experimental/otel/utils.ts create mode 100644 src/lib/experimental/vercel/_agent_type.ts create mode 100644 src/lib/experimental/vercel/index.ts create mode 100644 src/lib/experimental/vercel/middleware.ts create mode 100644 src/lib/experimental/vercel/telemetry.ts create mode 100644 src/lib/experimental/vercel/utils.ts create mode 100644 src/lib/experimental/vercel/wrap.ts create mode 100644 src/lib/index.ts create mode 100644 src/lib/jest/index.ts create mode 100644 src/lib/jest/reporter.ts create mode 100644 src/lib/langchain.ts create mode 100644 src/lib/run_trees.ts create mode 100644 src/lib/sandbox/README.md create mode 100644 src/lib/sandbox/access_delegation.ts create mode 100644 src/lib/sandbox/client.ts create mode 100644 src/lib/sandbox/command_handle.ts create mode 100644 src/lib/sandbox/errors.ts create mode 100644 src/lib/sandbox/helpers.ts create mode 100644 src/lib/sandbox/index.ts create mode 100644 src/lib/sandbox/mounts.ts create mode 100644 src/lib/sandbox/proxy_config.ts create mode 100644 src/lib/sandbox/sandbox.ts create mode 100644 src/lib/sandbox/service_url.ts create mode 100644 src/lib/sandbox/types.ts create mode 100644 src/lib/sandbox/verify.ts create mode 100644 src/lib/sandbox/ws_execute.ts create mode 100644 src/lib/schemas.ts create mode 100644 src/lib/singletons/constants.ts create mode 100644 src/lib/singletons/fetch.ts create mode 100644 src/lib/singletons/otel.ts create mode 100644 src/lib/singletons/traceable.ts create mode 100644 src/lib/singletons/types.ts create mode 100644 src/lib/tests/anonymizer.test.ts create mode 100644 src/lib/tests/anthropic_usage.test.ts create mode 100644 src/lib/tests/batch_client.int.test.ts create mode 100644 src/lib/tests/batch_client.test.ts create mode 100644 src/lib/tests/bun/traceable.test.bun.ts create mode 100644 src/lib/tests/claude_agent_sdk.int.test.ts create mode 100644 src/lib/tests/claude_agent_sdk.test.ts create mode 100644 src/lib/tests/client.int.test.ts create mode 100644 src/lib/tests/client.test.ts create mode 100644 src/lib/tests/client_headers.test.ts create mode 100644 src/lib/tests/client_retry.test.ts create mode 100644 src/lib/tests/construct_url.test.ts create mode 100644 src/lib/tests/context.int.test.ts create mode 100644 src/lib/tests/context.test.ts create mode 100644 src/lib/tests/error.test.ts create mode 100644 src/lib/tests/evaluate.int.test.ts create mode 100644 src/lib/tests/evaluate_attachments.int.test.ts create mode 100644 src/lib/tests/evaluate_runner.test.ts create mode 100644 src/lib/tests/experiment_manager.int.test.ts create mode 100644 src/lib/tests/failed_traces.test.ts create mode 100644 src/lib/tests/fetch.test.ts create mode 100644 src/lib/tests/git_info.test.ts create mode 100644 src/lib/tests/guard.vitesttest.ts create mode 100644 src/lib/tests/jestlike/experiment_metadata.test.ts create mode 100644 src/lib/tests/jestlike/globals.test.ts create mode 100644 src/lib/tests/jestlike/jest.int.test.ts create mode 100644 src/lib/tests/jestlike/jest.test.ts create mode 100644 src/lib/tests/jestlike/jest_existing_dataset.int.test.ts create mode 100644 src/lib/tests/jestlike/vitest.vitesteval.ts create mode 100644 src/lib/tests/jestlike/vitest_separate_file.vitesteval.ts create mode 100644 src/lib/tests/jestlike/wrap_jest.test.ts create mode 100644 src/lib/tests/jestlike/wrap_vitest.vitesteval.ts create mode 100644 src/lib/tests/lcls_handoff.int.test.ts create mode 100644 src/lib/tests/ls_agent_type.test.ts create mode 100644 src/lib/tests/node_fetch.int.test.ts create mode 100644 src/lib/tests/openai_agents_sdk.int.test.ts create mode 100644 src/lib/tests/openai_agents_sdk.test.ts create mode 100644 src/lib/tests/otel_translator.vitesttest.ts create mode 100644 src/lib/tests/perf.int.test.ts create mode 100644 src/lib/tests/profile-lock.test.ts create mode 100644 src/lib/tests/profile-oauth-discovery.test.ts create mode 100644 src/lib/tests/prompt_cache.test.ts create mode 100644 src/lib/tests/public_runs.int.test.ts create mode 100644 src/lib/tests/replica_endpoints.test.ts create mode 100644 src/lib/tests/run_stats.test.ts create mode 100644 src/lib/tests/run_trees.int.test.ts create mode 100644 src/lib/tests/run_trees.test.ts create mode 100644 src/lib/tests/run_trees.uuid.test.ts create mode 100644 src/lib/tests/runs_share.int.test.ts create mode 100644 src/lib/tests/sandbox.test.ts create mode 100644 src/lib/tests/sandbox_access_delegation.test.ts create mode 100644 src/lib/tests/sandbox_aws_role_auth.test.ts create mode 100644 src/lib/tests/sandbox_command_id_retry.test.ts create mode 100644 src/lib/tests/sandbox_error_handling.test.ts create mode 100644 src/lib/tests/sandbox_reconnect_ack.test.ts create mode 100644 src/lib/tests/sandbox_run_config_and_files.test.ts create mode 100644 src/lib/tests/sandbox_service_url.test.ts create mode 100644 src/lib/tests/sandbox_verify.test.ts create mode 100644 src/lib/tests/sandbox_ws_handshake.test.ts create mode 100644 src/lib/tests/serialize.test.ts create mode 100644 src/lib/tests/serialize_worker.test.ts create mode 100644 src/lib/tests/test_data/anthropic_managed_builtin_tools.jsonl create mode 100644 src/lib/tests/test_data/anthropic_managed_custom_tools.jsonl create mode 100644 src/lib/tests/test_data/anthropic_managed_mcp_tools.jsonl create mode 100644 src/lib/tests/test_data/anthropic_managed_mcp_tools_reject.jsonl create mode 100644 src/lib/tests/test_data/beemovie.txt create mode 100644 src/lib/tests/test_data/langsmith_js_wrap_openai_default.json create mode 100644 src/lib/tests/test_data/langsmith_js_wrap_openai_reasoning.json create mode 100644 src/lib/tests/test_data/langsmith_js_wrap_openai_stream.json create mode 100644 src/lib/tests/test_data/langsmith_js_wrap_openai_stream_no usage.json create mode 100644 src/lib/tests/test_data/parrot-icon.png create mode 100644 src/lib/tests/threads.int.test.ts create mode 100644 src/lib/tests/traceable.test.ts create mode 100644 src/lib/tests/traceable_langchain.test.ts create mode 100644 src/lib/tests/traces.int.test.ts create mode 100644 src/lib/tests/tracing_mode.test.ts create mode 100644 src/lib/tests/utils.ts create mode 100644 src/lib/tests/utils/markers.ts create mode 100644 src/lib/tests/utils/mock_client.ts create mode 100644 src/lib/tests/utils/tree.ts create mode 100644 src/lib/tests/utils/vitest_mock_client.ts create mode 100644 src/lib/tests/v2_migration.test.ts create mode 100644 src/lib/tests/vercel/telemetry/anthropic.int.test.ts create mode 100644 src/lib/tests/vercel/telemetry/harness-agent.int.test.ts create mode 100644 src/lib/tests/vercel/telemetry/harness-claude-code.int.test.ts create mode 100644 src/lib/tests/vercel/telemetry/methods.vitesttest.ts create mode 100644 src/lib/tests/vercel/telemetry/openai.int.test.ts create mode 100644 src/lib/tests/vercel/utils.ts create mode 100644 src/lib/tests/vercel/wrapper/methods.vitesttest.ts create mode 100644 src/lib/tests/vercel/wrapper/openai.int.test.ts create mode 100644 src/lib/tests/vercel/wrapper/sdk5-backcompat.vitesttest.ts create mode 100644 src/lib/tests/vercel_agent_type.test.ts create mode 100644 src/lib/tests/vitest_reporter.vitesttest.ts create mode 100644 src/lib/tests/wrapped_anthropic.int.test.ts create mode 100644 src/lib/tests/wrapped_anthropic_managed_agents.vitesttest.ts create mode 100644 src/lib/tests/wrapped_anthropic_mcp.test.ts create mode 100644 src/lib/tests/wrapped_gemini.int.test.ts create mode 100644 src/lib/tests/wrapped_gemini.vitesttest.ts create mode 100644 src/lib/tests/wrapped_openai.int.test.ts create mode 100644 src/lib/tests/wrapped_openai.test.ts create mode 100644 src/lib/traceable.ts create mode 100644 src/lib/utils/_git.ts create mode 100644 src/lib/utils/_uuid.ts create mode 100644 src/lib/utils/asserts.ts create mode 100644 src/lib/utils/async_caller.ts create mode 100644 src/lib/utils/atee.ts create mode 100644 src/lib/utils/chalk/license create mode 100644 src/lib/utils/chalk/source/index.ts create mode 100644 src/lib/utils/chalk/source/utilities.ts create mode 100644 src/lib/utils/chalk/source/vendor/ansi-styles/index.ts create mode 100644 src/lib/utils/chalk/source/vendor/supports-color/browser.ts create mode 100644 src/lib/utils/chalk/source/vendor/supports-color/index.ts create mode 100644 src/lib/utils/console-table-printer/LICENSE create mode 100644 src/lib/utils/console-table-printer/index.ts create mode 100644 src/lib/utils/console-table-printer/src/console-table-printer.ts create mode 100644 src/lib/utils/console-table-printer/src/internalTable/input-converter.ts create mode 100644 src/lib/utils/console-table-printer/src/internalTable/internal-table-printer.ts create mode 100644 src/lib/utils/console-table-printer/src/internalTable/internal-table.ts create mode 100644 src/lib/utils/console-table-printer/src/internalTable/table-pre-processors.ts create mode 100644 src/lib/utils/console-table-printer/src/models/common.ts create mode 100644 src/lib/utils/console-table-printer/src/models/external-table.ts create mode 100644 src/lib/utils/console-table-printer/src/models/internal-table.ts create mode 100644 src/lib/utils/console-table-printer/src/utils/colored-console-line.ts create mode 100644 src/lib/utils/console-table-printer/src/utils/console-utils.ts create mode 100644 src/lib/utils/console-table-printer/src/utils/string-utils.ts create mode 100644 src/lib/utils/console-table-printer/src/utils/table-constants.ts create mode 100644 src/lib/utils/console-table-printer/src/utils/table-helpers.ts create mode 100644 src/lib/utils/context_vars.ts create mode 100644 src/lib/utils/env.ts create mode 100644 src/lib/utils/error.ts create mode 100644 src/lib/utils/fast-safe-stringify/LICENSE create mode 100644 src/lib/utils/fast-safe-stringify/index.ts create mode 100644 src/lib/utils/fs.browser.ts create mode 100644 src/lib/utils/fs.ts create mode 100644 src/lib/utils/guard.ts create mode 100644 src/lib/utils/is-network-error/index.ts create mode 100644 src/lib/utils/is-network-error/license create mode 100644 src/lib/utils/jestlike/constants.ts create mode 100644 src/lib/utils/jestlike/globals.ts create mode 100644 src/lib/utils/jestlike/index.ts create mode 100644 src/lib/utils/jestlike/matchers.ts create mode 100644 src/lib/utils/jestlike/reporter.ts create mode 100644 src/lib/utils/jestlike/types.ts create mode 100644 src/lib/utils/jestlike/vendor/LICENSE create mode 100644 src/lib/utils/jestlike/vendor/chain.ts create mode 100644 src/lib/utils/jestlike/vendor/evaluatedBy.ts create mode 100644 src/lib/utils/ls_agent_type.ts create mode 100644 src/lib/utils/messages.ts create mode 100644 src/lib/utils/p-queue.ts create mode 100644 src/lib/utils/p-retry/index.ts create mode 100644 src/lib/utils/p-retry/license create mode 100644 src/lib/utils/profile-lock.ts create mode 100644 src/lib/utils/profiles.ts create mode 100644 src/lib/utils/project.ts create mode 100644 src/lib/utils/prompt_cache/index.ts create mode 100644 src/lib/utils/prompts.ts create mode 100644 src/lib/utils/redaction.ts create mode 100644 src/lib/utils/sampling.ts create mode 100644 src/lib/utils/serialize_worker.ts create mode 100644 src/lib/utils/shuffle.ts create mode 100644 src/lib/utils/simple-wcswidth/LICENSE create mode 100644 src/lib/utils/simple-wcswidth/index.ts create mode 100644 src/lib/utils/simple-wcswidth/src/binary-search.ts create mode 100644 src/lib/utils/simple-wcswidth/src/models.ts create mode 100644 src/lib/utils/simple-wcswidth/src/non-spacing-chars.ts create mode 100644 src/lib/utils/simple-wcswidth/src/wcswidth.ts create mode 100644 src/lib/utils/simple-wcswidth/src/wcwidth.ts create mode 100644 src/lib/utils/types.ts create mode 100644 src/lib/utils/url.ts create mode 100644 src/lib/utils/usage.ts create mode 100644 src/lib/utils/uuid/LICENSE.md create mode 100644 src/lib/utils/uuid/src/index.ts create mode 100644 src/lib/utils/uuid/src/max.ts create mode 100644 src/lib/utils/uuid/src/nil.ts create mode 100644 src/lib/utils/uuid/src/parse.ts create mode 100644 src/lib/utils/uuid/src/regex.ts create mode 100644 src/lib/utils/uuid/src/rng.ts create mode 100644 src/lib/utils/uuid/src/sha1.ts create mode 100644 src/lib/utils/uuid/src/stringify.ts create mode 100644 src/lib/utils/uuid/src/types.ts create mode 100644 src/lib/utils/uuid/src/v35.ts create mode 100644 src/lib/utils/uuid/src/v4.ts create mode 100644 src/lib/utils/uuid/src/v5.ts create mode 100644 src/lib/utils/uuid/src/v7.ts create mode 100644 src/lib/utils/uuid/src/validate.ts create mode 100644 src/lib/utils/uuid/src/version.ts create mode 100644 src/lib/utils/v2_migration.ts create mode 100644 src/lib/utils/vercel.ts create mode 100644 src/lib/utils/warn.ts create mode 100644 src/lib/utils/worker_threads.browser.ts create mode 100644 src/lib/utils/worker_threads.ts create mode 100644 src/lib/utils/xxhash/LICENSE create mode 100644 src/lib/utils/xxhash/xxhash.ts create mode 100644 src/lib/uuid.ts create mode 100644 src/lib/vitest/index.mts create mode 100644 src/lib/vitest/index.ts create mode 100644 src/lib/vitest/reporter.mts create mode 100644 src/lib/vitest/reporter.ts create mode 100644 src/lib/vitest/utils/esm.mts create mode 100644 src/lib/vitest/utils/reporter.ts create mode 100644 src/lib/vitest/utils/wrapper.ts create mode 100644 src/lib/wrappers/anthropic.ts create mode 100644 src/lib/wrappers/gemini.ts create mode 100644 src/lib/wrappers/gemini.utils.ts create mode 100644 src/lib/wrappers/generic.ts create mode 100644 src/lib/wrappers/index.ts create mode 100644 src/lib/wrappers/openai.ts create mode 100644 src/lib/wrappers/openai_agents.ts create mode 100644 src/lib/wrappers/utils/anthropic_managed.ts create mode 100644 src/pagination.ts create mode 100644 src/resource.ts create mode 100644 src/resources.ts create mode 100644 src/resources/annotation-queues.ts create mode 100644 src/resources/annotation-queues/annotation-queues.ts create mode 100644 src/resources/annotation-queues/index.ts create mode 100644 src/resources/annotation-queues/items.ts create mode 100644 src/resources/annotation-queues/runs.ts create mode 100644 src/resources/datasets.ts create mode 100644 src/resources/datasets/datasets.ts create mode 100644 src/resources/datasets/examples.ts create mode 100644 src/resources/datasets/experiment-runs.ts create mode 100644 src/resources/datasets/index.ts create mode 100644 src/resources/fleet.ts create mode 100644 src/resources/fleet/fleet.ts create mode 100644 src/resources/fleet/index.ts create mode 100644 src/resources/fleet/threads.ts create mode 100644 src/resources/index.ts create mode 100644 src/resources/info.ts create mode 100644 src/resources/issues.ts create mode 100644 src/resources/online-evaluators.ts create mode 100644 src/resources/product-feedback.ts create mode 100644 src/resources/public.ts create mode 100644 src/resources/public/index.ts create mode 100644 src/resources/public/public.ts create mode 100644 src/resources/public/runs.ts create mode 100644 src/resources/runs.ts create mode 100644 src/resources/runs/index.ts create mode 100644 src/resources/runs/runs.ts create mode 100644 src/resources/runs/share.ts create mode 100644 src/resources/sandboxes.ts create mode 100644 src/resources/sandboxes/boxes.ts create mode 100644 src/resources/sandboxes/index.ts create mode 100644 src/resources/sandboxes/registries.ts create mode 100644 src/resources/sandboxes/sandboxes.ts create mode 100644 src/resources/sandboxes/snapshots.ts create mode 100644 src/resources/shared.ts create mode 100644 src/resources/threads.ts create mode 100644 src/resources/threads/index.ts create mode 100644 src/resources/threads/share.ts create mode 100644 src/resources/threads/threads.ts create mode 100644 src/resources/traces.ts create mode 100644 src/run_trees.ts create mode 100644 src/sandbox.ts create mode 100644 src/schemas.ts create mode 100644 src/singletons/traceable.ts create mode 100644 src/traceable.ts create mode 100644 src/uploads.ts create mode 100644 src/utils/jestlike.ts create mode 100644 src/version.ts create mode 100644 src/vitest.mts create mode 100644 src/vitest/reporter.mts create mode 100644 src/wrappers.ts create mode 100644 src/wrappers/anthropic.ts create mode 100644 src/wrappers/gemini.ts create mode 100644 src/wrappers/openai.ts create mode 100644 src/wrappers/openai_agents.ts create mode 100644 tests/api-resources/annotation-queues/annotation-queues.test.ts create mode 100644 tests/api-resources/annotation-queues/items.test.ts create mode 100644 tests/api-resources/annotation-queues/runs.test.ts create mode 100644 tests/api-resources/datasets/examples.test.ts create mode 100644 tests/api-resources/datasets/experiment-runs.test.ts create mode 100644 tests/api-resources/fleet/threads.test.ts create mode 100644 tests/api-resources/info.test.ts create mode 100644 tests/api-resources/issues.test.ts create mode 100644 tests/api-resources/online-evaluators.test.ts create mode 100644 tests/api-resources/product-feedback.test.ts create mode 100644 tests/api-resources/public/runs.test.ts create mode 100644 tests/api-resources/runs/runs.test.ts create mode 100644 tests/api-resources/runs/share.test.ts create mode 100644 tests/api-resources/sandboxes/boxes.test.ts create mode 100644 tests/api-resources/sandboxes/registries.test.ts create mode 100644 tests/api-resources/sandboxes/sandboxes.test.ts create mode 100644 tests/api-resources/sandboxes/snapshots.test.ts create mode 100644 tests/api-resources/threads/share.test.ts create mode 100644 tests/api-resources/threads/threads.test.ts create mode 100644 tests/api-resources/traces.test.ts create mode 100644 tests/base64.test.ts create mode 100644 tests/buildHeaders.test.ts create mode 100644 tests/form.test.ts create mode 100644 tests/index.test.ts create mode 100644 tests/path.test.ts create mode 100644 tests/qs/empty-keys-cases.ts create mode 100644 tests/qs/stringify.test.ts create mode 100644 tests/qs/utils.test.ts create mode 100644 tests/runs-aliases.test.ts create mode 100644 tests/stringifyQuery.test.ts create mode 100644 tests/uploads.test.ts create mode 100644 tsc-multi.json create mode 100644 tsc-multi.vitest.json create mode 100644 tsconfig.build.json create mode 100644 tsconfig.build.vitest.json create mode 100644 tsconfig.deno.json create mode 100644 tsconfig.dist-src.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json create mode 100644 typedoc.json create mode 100644 vitest.config.ts diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..cb95e3f --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Development", + "image": "mcr.microsoft.com/devcontainers/typescript-node:latest", + "features": { + "ghcr.io/devcontainers/features/node:1": {} + }, + "postCreateCommand": "pnpm install", + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..e0a946a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,76 @@ +name: "\U0001F41B Bug Report" +description: 'Submit a bug report to help us improve LangSmith. To report a _security_ issue, please instead use the security option below.' +labels: ['01 Bug Report'] +body: + - type: markdown + attributes: + value: > + Thank you for taking the time to file a bug report. Before creating a new + issue, please make sure to take a few moments to check the issue tracker + for existing issues about the bug. + + - type: checkboxes + id: tracing-method + attributes: + label: Tracing Method + description: 'Select whether you are tracing using LangChain or some other method:' + options: + - label: 'SDK/Client' + - label: 'REST API' + - label: 'With LangChain' + - label: 'Other' + + - type: checkboxes + id: runtime-language + attributes: + label: Language + description: '' + options: + - label: 'JavaScript/TypeScript' + - label: 'Other' + + - type: checkboxes + id: platform-environment + attributes: + label: Host + description: 'Indicate whether you are connected to the hosted LangSmith platform or running locally.' + options: + - label: 'Hosted (https://api.smith.langchain.com)' + - label: 'Self-hosted' + - label: 'Other' + + - type: textarea + id: system-info + attributes: + label: Other System Info + description: Please share any other system info with us. You can view this by running `langsmith env` in your terminal. + placeholder: LangSmith SDK version, client runtime information, + validations: + required: true + + - type: textarea + id: reproduction + validations: + required: true + attributes: + label: Reproduction + description: | + Please provide a [code sample](https://stackoverflow.com/help/minimal-reproducible-example) that reproduces the problem you ran into. It can be a Colab link or just a code snippet. + If you have code snippets, error messages, stack traces please provide them here as well. + Important! Use code tags to correctly format your code. See https://help.github.com/en/github/writing-on-github/creating-and-highlighting-code-blocks#syntax-highlighting + Avoid screenshots when possible, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code. + + placeholder: | + Steps to reproduce the behavior: + + 1. + 2. + 3. + + - type: textarea + id: expected-behavior + validations: + required: true + attributes: + label: Expected behavior + description: 'A clear and concise description of what you would expect to happen.' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..108f43a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,9 @@ +blank_issues_enabled: true +version: 2.1 +contact_links: + - name: Support Portal + url: https://support.langchain.com + about: Contact technical support for enterprise support requests. + - name: Discord + url: https://discord.gg/6adMQxSpJS + about: General community discussions diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml new file mode 100644 index 0000000..216745f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -0,0 +1,19 @@ +name: Documentation +description: Report an issue related to the LangSmith documentation. +title: "DOC: " +labels: [03 - Documentation] + +body: + - type: textarea + attributes: + label: 'Issue with current documentation:' + description: > + Please make sure to leave a reference to the document/code you're + referring to. + + - type: textarea + attributes: + label: 'Idea or request for content:' + description: > + Please describe as clearly as possible what topics you think are missing + from the current documentation. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..144cdd8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,21 @@ +name: 'Feature request' +description: Submit a proposal/request for a new LangSmith feature +labels: ['02 Feature Request'] +body: + - type: textarea + id: feature-request + validations: + required: true + attributes: + label: Feature request + description: | + A clear and concise description of the feature proposal. Please provide links to any relevant GitHub repos, papers, or other resources if relevant. + + - type: textarea + id: motivation + validations: + required: true + attributes: + label: Motivation + description: | + Please outline the motivation for the proposal. Is your feature request related to a problem? e.g., I'm always frustrated when [...]. If this is related to another GitHub issue, please link here too. diff --git a/.github/ISSUE_TEMPLATE/other.yml b/.github/ISSUE_TEMPLATE/other.yml new file mode 100644 index 0000000..5fadfe4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/other.yml @@ -0,0 +1,18 @@ +name: Other Issue +description: Raise an issue that wouldn't be covered by the other templates. +title: "Issue: " +labels: [04 - Other] + +body: + - type: textarea + attributes: + label: "Issue you'd like to raise." + description: > + Please describe the issue you'd like to raise as clearly as possible. + Make sure to include any relevant links or references. + + - type: textarea + attributes: + label: 'Suggestion:' + description: > + Please outline a suggestion to improve the issue here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5b8314a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,38 @@ +version: 2 +updates: + - package-ecosystem: 'npm' + directory: '/' + target-branch: 'main' + schedule: + interval: 'monthly' + ignore: + - dependency-name: 'p-queue' + groups: + minor-and-patch: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' + major: + patterns: + - '*' + update-types: + - 'major' + - package-ecosystem: 'github-actions' + directory: '/' + target-branch: 'main' + schedule: + interval: 'monthly' + groups: + minor-and-patch: + patterns: + - '*' + update-types: + - 'minor' + - 'patch' + major: + patterns: + - '*' + update-types: + - 'major' diff --git a/.github/scripts/README.md b/.github/scripts/README.md new file mode 100644 index 0000000..e8317dd --- /dev/null +++ b/.github/scripts/README.md @@ -0,0 +1,33 @@ +# Mirror maintenance + +Pulls new `langsmith-sdk/js` commits hourly into one rolling PR. +To run manually, dispatch **Mirror from langsmith-sdk** from `main`. +Manual fixes on the mirror branch survive later runs. Slack notifies Thibaut of updates and failures. + +## Resolve conflicts + +1. Fix conflicts directly on the mirror branch. Check the PR and run artifacts for unapplied patches. +2. Push the fixes and remove `needs-manual-merge`. +3. Dispatch from `main` to resume. + +## Review and merge + +1. Port files listed as manual; check new public modules for entrypoint shims and exports. +2. Wait for CI to pass, then **rebase-merge** the mirror PR. +3. Rebase-merge the production promotion PR too. Check the release changelog before publishing. + +Enable **Allow rebase merging** in repository settings if needed. +Closing an unmerged mirror PR discards its fixes; the next run replays from `main`. + +## Formatting policy + +Prettier formats handwritten and generated code. `.prettierrc.json` retains the +80-column, double-quote override for `src/lib/`; the version comes from `pnpm-lock.yaml`. + +1. Run `scripts/format` locally. `scripts/lint` checks formatting without changing files. +2. Mirroring formats changed files after clean patch application, before committing. +3. Conflicted patches keep their markers. Run `scripts/format` after resolving them. +4. Keep formatter configuration in staging; upstream tooling files remain manual ports. + +STLC seals custom changes from `main` and runs `scripts/lint` after regeneration. +Formatting-only changes can still conflict with later upstream edits; review them normally. diff --git a/.github/scripts/format-mirror.sh b/.github/scripts/format-mirror.sh new file mode 100755 index 0000000..57b0d1b --- /dev/null +++ b/.github/scripts/format-mirror.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Format changed mirror files with the destination config before committing. +set -euo pipefail +cd "$1" + +# Preserve conflict markers for manual resolution. +if [ -n "$(git diff --name-only --diff-filter=U)" ]; then + echo "Resolve mirror conflicts before formatting." >&2 + exit 1 +fi + +files=() +while IFS= read -r -d '' file; do + case "$file" in + src/lib/*) files+=("$file") ;; + esac +done < <(git diff --name-only --diff-filter=ACM -z HEAD) + +if ((${#files[@]})); then + ./node_modules/.bin/prettier --write --ignore-unknown -- "${files[@]}" +fi diff --git a/.github/scripts/mirror-from-langsmith-sdk.sh b/.github/scripts/mirror-from-langsmith-sdk.sh new file mode 100755 index 0000000..cffc5f6 --- /dev/null +++ b/.github/scripts/mirror-from-langsmith-sdk.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Rewrite one langsmith-sdk commit for this repo and apply it with git apply --3way. +# Usage: mirror-from-langsmith-sdk.sh [out-dir] +# Run from a langsmith-sdk checkout. Writes to out-dir: status (clean|conflict|noop) and +# one-path-per-line lists manual, dropped, conflicts. +set -euo pipefail +lang=$1 sha=$2 staging=$(cd "$3" && pwd) out=${4:-$(mktemp -d)} +mkdir -p "$out"; out=$(cd "$out" && pwd) + + +case $lang in + python) + root=python gen=python/langsmith/_openapi_client + mapped='python/langsmith/|python/tests/|python/bench/' + # header lines only: python/langsmith -> src/langsmith, python/tests -> tests, python/bench -> bench + paths='s#(^| |/)python/langsmith/#\1src/langsmith/#g; s#(^| |/)python/(tests|bench)/#\1\2/#g' + text='s/langsmith\._openapi_client/langsmith/g' + ;; + js) + root=js gen=js/src/_openapi_client + mapped='js/src/' + paths='s#(^| |/)js/src/#\1src/lib/#g' + # generated client sits one level above src/lib; its index is client.ts there + text='s#"\.\./_openapi_client/index\.js"#"../../client.js"#g; s#"\./_openapi_client/index\.js"#"../client.js"#g; s#"\.\./_openapi_client/#"../../#g; s#"\./_openapi_client/#"../#g' + ;; + *) echo "lang must be python or js" >&2; exit 2 ;; +esac + +rewrite=() dropped=() manual=() +while IFS= read -r f; do + if [[ $f == $gen/* ]]; then dropped+=("$f") + elif [[ $f =~ ^($mapped) ]]; then rewrite+=("$f") + else manual+=("$f") + fi +done < <(git diff --name-only --no-renames "$sha^" "$sha" -- "$root") + +lines() { if (($#)); then printf '%s\n' "$@"; fi; } + +lines ${dropped[@]+"${dropped[@]}"} > "$out/dropped" +lines ${manual[@]+"${manual[@]}"} > "$out/manual" +: > "$out/conflicts" +if ((${#rewrite[@]} == 0)); then echo noop > "$out/status"; exit 0; fi + +git diff --binary --full-index --no-renames "$sha^" "$sha" -- "${rewrite[@]}" \ + | sed -E "/^(diff --git |--- |\+\+\+ |rename (from|to) |copy (from|to) )/ { $paths; }" \ + | sed -E "$text" > "$out/$lang.patch" + +# --3way needs the preimage blobs in the target repo, rewritten like the patch so the index hashes match +for f in "${rewrite[@]}"; do + old=$(git rev-parse -q --verify "$sha^:$f" 2>/dev/null) || continue + new=$(git cat-file blob "$old" | sed -E "$text" | git -C "$staging" hash-object -w --stdin) + [[ $old == "$new" ]] || sed -i.bak "s/^index $old\.\./index $new../" "$out/$lang.patch" +done +rm -f "$out/$lang.patch.bak" + +# apply per file so one unmergeable file does not abort the rest +awk -v o="$out" '/^diff --git /{n++} {print > sprintf("%s/chunk-%03d", o, n)}' "$out/$lang.patch" +failed=() +for c in "$out"/chunk-*; do + before=$(git -C "$staging" diff --name-only --diff-filter=U | wc -l) + if ! git -C "$staging" apply --3way "$c" 2>>"$out/apply.log"; then + after=$(git -C "$staging" diff --name-only --diff-filter=U | wc -l) + ((after > before)) || failed+=("$(sed -nE 's#^diff --git a/(.*) b/.*#\1#p;q' "$c")") + fi +done +conflicts=() +while IFS= read -r f; do [[ -n $f ]] && conflicts+=("$f"); done < <(git -C "$staging" diff --name-only --diff-filter=U) + +lines ${conflicts[@]+"${conflicts[@]}"} > "$out/conflicts" +for f in ${failed[@]+"${failed[@]}"}; do echo "$f (not applied, patch in the run artifact)"; done >> "$out/conflicts" + +if ((${#conflicts[@]} + ${#failed[@]})); then echo conflict > "$out/status" +elif git -C "$staging" diff --quiet HEAD; then echo noop > "$out/status" +else echo clean > "$out/status" +fi diff --git a/.github/scripts/test-format-mirror.sh b/.github/scripts/test-format-mirror.sh new file mode 100755 index 0000000..9191ab1 --- /dev/null +++ b/.github/scripts/test-format-mirror.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Exercise the mirror formatter with the installed, locked formatter. +set -euo pipefail +repo=$(cd "$(dirname "$0")/../.." && pwd) +fixture=$(mktemp -d) +trap 'rm -rf "$fixture"' EXIT +cd "$fixture" +git init -q +git config user.name "Mirror formatter test" +git config user.email "mirror-test@example.invalid" +git config core.hooksPath /dev/null +ln -s "$repo/node_modules" "$fixture/node_modules" +cp "$repo/.prettierrc.json" "$repo/.prettierignore" "$fixture/" +mkdir -p src/lib +file="src/lib/format fixture.ts" +unrelated=unrelated.ts +bad='const value={answer:42}' +good='const value = { answer: 42 };' +printf '%s\n' "$good" > "$file" +printf '%s\n' "$good" > "$unrelated" +git add -- "$file" "$unrelated" +git commit -qm baseline + +# Changed tracked files with spaces are formatted; unrelated and untracked files are not. +printf '%s\n' "$bad" > "$file" +printf '%s\n' "$bad" > "$unrelated" +printf '%s\n' "$bad" > untracked.py +git add -- "$file" +"$repo/.github/scripts/format-mirror.sh" "$fixture" +test "$(cat "$file")" = "$good" +test "$(cat "$unrelated")" = "$bad" +test "$(cat untracked.py)" = "$bad" + +# Deleted files and an empty formatting selection must not invoke a formatter on the whole repo. +git add -- "$file" +git rm -q -- "$file" +"$repo/.github/scripts/format-mirror.sh" "$fixture" +test ! -f "$file" +test "$(cat "$unrelated")" = "$bad" + +# Unmerged entries must fail without rewriting conflict markers. +mkdir -p "$(dirname "$file")" +printf '%s\n' '<<<<<<< HEAD' 'left' '=======' 'right' '>>>>>>> upstream' > "$file" +blob=$(printf '%s\n' "$good" | git hash-object -w --stdin) +printf '100644 %s 1\t%s\n100644 %s 2\t%s\n100644 %s 3\t%s\n' \ + "$blob" "$file" "$blob" "$file" "$blob" "$file" | git update-index --index-info +cp "$file" before-conflict +if "$repo/.github/scripts/format-mirror.sh" "$fixture"; then + echo "Expected formatting to reject unresolved conflicts" >&2 + exit 1 +fi +cmp "$file" before-conflict +echo "Mirror formatting regression checks passed." diff --git a/.github/sdk-reconciliation.md b/.github/sdk-reconciliation.md new file mode 100644 index 0000000..ea145cc --- /dev/null +++ b/.github/sdk-reconciliation.md @@ -0,0 +1,60 @@ +# Reconcile files excluded from SDK mirroring + +Audit `langchain-ai/langsmith-sdk` through `1322234d3b88497955cf66851500ebc6910ee85e` (2 October 2026). + +Initial mirror baseline: `55d31d21d8417d55b705941cb0205fc1f6487858`. +Staging code watermark at this audit: `842d056d001e9bc7faf543da89278613dfebc284`. + +This audit accounts for excluded metadata and configuration. It does not advance the code watermark or claim the remaining code queue is drained. + +## Source changes + +Inspect every source commit after the initial mirror baseline, including commits with only excluded files and shared `.github/` changes. Check prior `Mirror-Manual-Port` records. This avoids treating an advanced watermark as proof of metadata parity. + +| Source commit | Excluded paths | Status | Evidence or reason | +| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [ad399f1d](https://github.com/langchain-ai/langsmith-sdk/commit/ad399f1da7a775119eda41e2644be1c050153445) | `js/package.json` | Excluded intentionally | Keep source release history and version bumps out of staging. Release-please owns staging version files; see the first-publication gate below. | +| [1d3e54ae](https://github.com/langchain-ai/langsmith-sdk/commit/1d3e54ae6bcb28cdf959553378734a0add0a279a) | `.github/scripts/mirror-since.sh`
`.github/scripts/mirror-to-staging.sh`
`.github/workflows/mirror-to-staging.yml` | Already present | Staging pulls through .github/workflows/mirror-from-langsmith-sdk.yml. The old push-based workflow is superseded. | +| [9207dfb6](https://github.com/langchain-ai/langsmith-sdk/commit/9207dfb6dfc7dea53b9574ce8ff37858e379dcea) | `.github/scripts/mirror-since.sh`
`.github/scripts/mirror-to-staging.sh`
`.github/workflows/mirror-to-staging.yml` | Already present | No push-based mirror workflow exists in staging; the pull workflow remains authoritative. | +| [3e225e3a](https://github.com/langchain-ai/langsmith-sdk/commit/3e225e3a1aec4f0b0e2b0bd3a188dc79da56d320) | `js/pnpm-workspace.yaml` | Manually ported | Set root minimumReleaseAge to 10080 minutes; preserve the existing package exceptions, including langsmith for local fixtures. | +| [b6273eb7](https://github.com/langchain-ai/langsmith-sdk/commit/b6273eb709f0b311d5daf8dfa0f4732fc30cec7e) | `js/internal/environment_tests/test-exports-webpack/package.json`
`js/internal/environment_tests/test-exports-webpack/pnpm-lock.yaml`
`js/package.json`
`js/pnpm-lock.yaml` | Manually ported | Update root fast-uri 3.1.8, ip-address 10.7.1, undici 7.29.1/8.10.2 overrides and the webpack fast-uri override; regenerate both locks. | +| [ca941215](https://github.com/langchain-ai/langsmith-sdk/commit/ca941215c61d94e6f394342fdf4c7dec9accff1d) | `js/internal/environment_tests/test-exports-cf/pnpm-lock.yaml` | Manually ported | Pin Cloudflare fixture undici to 7.29.1 and regenerate its lock. Retain the tested Wrangler/workerd versions; source also refreshed unrelated fixture packages. | +| [51e9f233](https://github.com/langchain-ai/langsmith-sdk/commit/51e9f23353836a44a7fa53e60eaeed9ac85cc739) | `js/package.json` | Excluded intentionally | Keep source release history and version bumps out of staging. Release-please owns staging version files; see the first-publication gate below. | +| [ff34d38d](https://github.com/langchain-ai/langsmith-sdk/commit/ff34d38d91cbd4041972e813ab1bd864ebe5b17f) | `js/package.json` | Excluded intentionally | Keep source release history and version bumps out of staging. Release-please owns staging version files; see the first-publication gate below. | +| [5077435f](https://github.com/langchain-ai/langsmith-sdk/commit/5077435f36aab3825840bdcc4548849b3fc8543a) | `AGENTS.md`
`CONTRIBUTING.md` | Manually ported | Add the external contributor takeover policy and agent link. Adapt the internal repository and branch prefix to staging; preserve review-before-push and contributor authorship. | +| [1322234d](https://github.com/langchain-ai/langsmith-sdk/commit/1322234d3b88497955cf66851500ebc6910ee85e) | `.github/workflows/ci.yml` | Excluded intentionally | Adds an old-repo Slack recipient and Open SWE investigation prompt. Preserve staging notification routing; no SDK or test behavior changes. | + +Generated `_openapi_client` files are intentionally excluded. The staging REST client is generated from STLC/Stainless and sealed separately. Source generated blobs must not overwrite it. + +## Manifest and packaging decisions + +| Area | Decision | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Published dependencies and peers | Runtime p-queue and peer dependencies match source. Preserve optional peer declarations. | +| Security overrides | Copy the four source fixes. Retain stronger staging brace-expansion/minimatch overrides and all existing reviewed overrides. | +| Dev tooling | Keep Stainless TypeScript 5.8.3, SWC, ESLint/Prettier, tsc-multi, attw and publint. Do not replace them with source TypeScript 6/Babel/Oxlint tooling or downgrade newer OTel dependencies. | +| Package manager | Keep pnpm 10.30.1 with matching CI pins. A source pnpm 10.33.0 toolchain migration is intentionally excluded. | +| Metadata | Keep package name langsmith and production repository URL. Match the source package description. | +| Exports and packaging | Preserve root shims, nested paths, conditional CJS/ESM exports, ESM-only vitest, browser replacements and dist publication. The source entrypoint generator/layout cannot be copied over Stainless packaging. | +| Export fixtures | Port webpack/Cloudflare security changes into their standalone projects. Preserve staging file:../../../dist links rather than copying source file:../../.. links. | +| Dependency maintenance | Target main because next does not exist. Keep the source p-queue ignore so update automation cannot replace the intentional CJS-compatible runtime dependency. | + +## CI and release decisions + +Keep staging build, unit, integration, package-import/export and generation checks. Source root CI changes in this range only add old-repo notification routing; they do not add missing test behavior. Shared Makefile/workspace files had no additional changes in this range. + +Keep staging promotion and publishing workflows separate from old-repo release workflows. This PR does not enable promotion, publish a package, change repository visibility, or modify generated files. + +1. First publication must use release-please to select a version above the published source SDK version: 0.10.7 (staging is currently 0.10.4). +2. Reconcile the manifest, package.json and src/version.ts together through the release process. Do not copy skipped source version bumps manually. +3. Confirm the intended stable/prerelease channel and finish the production publishing pipeline under LIN-505/LIN-506 before publishing. Staging retains its current prerelease configuration. + +## Validation + +1. Fresh `pnpm install --frozen-lockfile --ignore-scripts`: passed. Only fast-uri, ip-address and the two undici versions change in the root lock; webpack/Cloudflare locks contain only their security changes. +2. Build, both TypeScript checks, Prettier, ESLint, attw and publint passed. ESLint has existing unused-disable warnings; publint retains two existing ESM-only vitest export warnings. The initial attw invocation could not write to the default npm cache; it passed with a temporary npm cache. +3. Client, sandbox and run-tree suites: 318 passed. Generated suite: 331 passed, 124 skipped. The broad handwritten run was stopped after it failed the existing 404 tracing test and stopped progressing. That failure also reproduces on unchanged staging with its original dependencies, and passes with an isolated LANGSMITH_CONFIG_FILE. Full handwritten coverage is a CI gate, not a claimed local pass. +4. Webpack production bundle and Cloudflare Wrangler dry run passed with fresh fixture installs. Both standalone lockfiles pass frozen-lockfile checks. +5. Dependency audit reports four unrelated advisories (two high, two moderate), with none for fast-uri, ip-address or undici. The source security patches are reconciled; this PR does not claim a clean audit for the entire development tree. + +Logs are retained with the local reconciliation checkout. Live integrations, other export fixtures and CI remain required before release. diff --git a/.github/workflows/auto-label-issues.yml b/.github/workflows/auto-label-issues.yml new file mode 100644 index 0000000..c034653 --- /dev/null +++ b/.github/workflows/auto-label-issues.yml @@ -0,0 +1,24 @@ +name: Auto Label Issues + +on: + issues: + types: [opened] + +permissions: {} + +jobs: + auto-label: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Add SDK label + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + github.rest.issues.addLabels({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + labels: ['sdk'] + }) diff --git a/.github/workflows/check-pr-source.yml b/.github/workflows/check-pr-source.yml new file mode 100644 index 0000000..81a8437 --- /dev/null +++ b/.github/workflows/check-pr-source.yml @@ -0,0 +1,36 @@ +name: Check PR source + +# Production main only takes the stlc promotion PR (stainless/release) and the +# release-please PR. Urgent fix: add the `skip-pr-source-check` label, then run +# "stlc: sync from production" in the staging repo. + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: {} + +jobs: + check: + name: PR comes from staging + if: github.repository == 'langchain-ai/langsmith-javascript' + runs-on: ubuntu-latest + steps: + - name: Check the head branch + env: + HEAD: ${{ github.head_ref }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'skip-pr-source-check') }} + run: | + if [ "$SKIP" = "true" ]; then + echo "::warning::skip-pr-source-check label present. After merging, run 'stlc: sync from production' in langsmith-javascript-staging so staging catches up." + exit 0 + fi + if [ "$SAME_REPO" = "true" ]; then + case "$HEAD" in + stainless/release|release-please--branches--main) exit 0 ;; + esac + fi + echo "::error::PRs to main must come from stainless/release. Open the change against langsmith-javascript-staging instead, or add the skip-pr-source-check label for an urgent production fix." + exit 1 diff --git a/.github/workflows/check-stainless-file-modification.yml b/.github/workflows/check-stainless-file-modification.yml new file mode 100644 index 0000000..ef553fb --- /dev/null +++ b/.github/workflows/check-stainless-file-modification.yml @@ -0,0 +1,74 @@ +name: Check Stainless File Modification + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - labeled + - unlabeled + +permissions: + contents: read + pull-requests: read + +jobs: + check-stainless-files: + name: Check for Stainless-generated file modifications + runs-on: ubuntu-latest + if: github.repository == 'langchain-ai/langsmith-javascript-staging' + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Check for modifications to Stainless-generated files + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + SKIP_LABEL="skip-stainless-generated-file-modification-check" + + # Check if the skip label is present + LABELS=$(gh api repos/"$REPO"/pulls/"$PR_NUMBER" --jq '[.labels[].name] | join(",")') + if echo "$LABELS" | grep -qF "$SKIP_LABEL"; then + echo "Skip label '$SKIP_LABEL' is present — skipping check." + exit 0 + fi + + # Get the list of files changed in this PR + CHANGED_FILES=$(git diff --name-only "origin/${{ github.base_ref }}"...HEAD) + + STAINLESS_FILES="" + while IFS= read -r FILE; do + [ -z "$FILE" ] && continue + [ ! -f "$FILE" ] && continue + # Generated files carry the marker in their first-line header. + # Documentation and this workflow may quote it later in the file. + if head -n 1 "$FILE" | grep -qF "File generated from our OpenAPI spec by Stainless."; then + STAINLESS_FILES="$STAINLESS_FILES\n - $FILE" + fi + done <<< "$CHANGED_FILES" + + if [ -n "$STAINLESS_FILES" ]; then + echo "::error::This PR modifies Stainless-generated files, which should not be edited directly." + printf "Stainless-generated files modified:%b\n" "$STAINLESS_FILES" + echo "" + echo "Please choose one of the following options:" + echo "" + echo " 1. Refactor your change to avoid modifying the generated file directly." + echo "" + echo " 2. Use Stainless configuration instead:" + echo " - OpenAPI extensions: https://www.stainless.com/docs/openapi/extensions/" + echo " - Transforms: https://www.stainless.com/docs/openapi/transforms/reference/" + echo "" + echo " 3. If neither option above is feasible, add the label '$SKIP_LABEL' to this PR to bypass this check." + echo " Use this only as a last resort and document why it is necessary." + exit 1 + fi + + echo "No Stainless-generated files were modified." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9aa653c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,348 @@ +name: CI +on: + push: + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + schedule: + # Nightly: every job, every environment, Slack on failure. + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + integration-environment: + description: LangSmith environment for the integration suite + type: choice + default: beta + options: [beta, v16-ch-only, v16-dual, v16-smithdb-only, all] + exit-first: + description: Stop at the first failing test + type: boolean + default: false + +permissions: + contents: read + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ubuntu-latest + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Test mirror formatting + run: .github/scripts/test-format-mirror.sh + + - name: Check types + run: ./scripts/lint + + test: + timeout-minutes: 20 + name: 'test (${{ matrix.os }}, node ${{ matrix.node-version }})' + runs-on: ${{ matrix.os }} + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + defaults: + run: + shell: bash # scripts/* are bash; windows defaults to pwsh + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node-version: ['22', '24'] + include: + - os: windows-latest + node-version: '24' + - os: macos-latest + node-version: '24' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: ${{ matrix.node-version }} + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + # The Stainless `prepare` hook is a bash one-liner; pnpm runs hooks via cmd.exe on windows. + run: ./scripts/bootstrap ${{ runner.os == 'Windows' && '--ignore-scripts' || '' }} + + # Same as langsmith-sdk's `js-test` job: jest unit tests (integration tests need API keys), then vitest. + - name: Run tests + run: ./scripts/test + + - name: Run vitest tests + run: pnpm test:vitest + + build: + timeout-minutes: 5 + name: build + runs-on: ubuntu-latest + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Check build + run: ./scripts/build + + # Built package installed from dist into a clean container per consumer: + # one per bundler plus deepagents, like langsmith-sdk's js-exports-* and + # js-deepagent-compat jobs. + exports: + timeout-minutes: 15 + name: 'exports (${{ matrix.target }})' + runs-on: ubuntu-latest + if: (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork) && (github.event_name != 'push' || github.event.head_commit.message != 'codegen metadata') + strategy: + fail-fast: false + matrix: + target: [esm, cjs, cf, vite, webpack, esbuild, metro, deepagent-compat] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Build + run: ./scripts/build + + - name: Test exports + run: docker compose -f internal/environment_tests/docker-compose.yml run ${{ matrix.target == 'deepagent-compat' && 'test-deepagent-compat' || format('test-exports-{0}', matrix.target) }} + + # Live suite against a LangSmith deployment: main pushes hit beta, the + # nightly hits every environment, a dispatch picks one. + integration-matrix: + name: 'integration matrix' + runs-on: ubuntu-latest + # Staging only: the prod repo has no API-key secrets and receives promoted code. + if: github.repository == 'langchain-ai/langsmith-javascript-staging' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) + outputs: + matrix: ${{ steps.build.outputs.matrix }} + steps: + - id: build + env: + SELECTED: ${{ github.event_name == 'schedule' && 'all' || github.event.inputs.integration-environment || 'beta' }} + run: | + python3 <<'EOF' >> "$GITHUB_OUTPUT" + import json, os + ENDPOINTS = { + "beta": "https://beta.api.smith.langchain.com", + "v16-ch-only": "https://aks.smith.langchain.dev/api", + "v16-dual": "https://stable-dual.eks.smith.langchain.dev/api", + "v16-smithdb-only": "https://stable-smithdb.eks.smith.langchain.dev/api", + } + # SmithDB-only lacks the legacy ClickHouse endpoints, ClickHouse-only lacks v2, + # only the beta tenant resolves the shared dataset token, and the provider + # wrapper tests (mocked LangSmith client) run once, on beta. + EXCLUDED = {"v16-ch-only": ["require_v2"], "v16-smithdb-only": ["require_clickhouse"]} + selected = os.environ["SELECTED"] + envs = list(ENDPOINTS) if selected == "all" else [selected if selected in ENDPOINTS else "beta"] + include = [] + for env in envs: + markers = EXCLUDED.get(env, []) + ([] if env == "beta" else ["require_beta_dataset", "require_provider"]) + include.append({ + "environment": env, + "endpoint": ENDPOINTS[env], + "suffix": "" if env == "beta" else f" [{env}]", + "exclude_markers": ",".join(markers), + }) + print("matrix=" + json.dumps({"include": include})) + EOF + + integration: + name: 'integration${{ matrix.suffix }}' + needs: integration-matrix + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.integration-matrix.outputs.matrix) }} + env: + LANGSMITH_TRACING_V2: 'true' + LANGSMITH_ENDPOINT: ${{ matrix.endpoint }} + LANGSMITH_API_KEY: >- + ${{ (matrix.environment == 'v16-ch-only' && secrets.LANGSMITH_API_KEY_AKS_V15) + || (matrix.environment == 'v16-dual' && secrets.LANGSMITH_API_KEY_STABLE_DUAL) + || (matrix.environment == 'v16-smithdb-only' && secrets.LANGSMITH_API_KEY_STABLE_SMITHDB) + || secrets.LANGSMITH_API_KEY_BETA }} + LANGSMITH_TEST_EXCLUDE_MARKERS: ${{ matrix.exclude_markers }} + # Provider calls go through the prod LLM Gateway: its workspace holds all + # three provider secrets. The LangSmith deployment under test is separate. + OPENAI_BASE_URL: https://gateway.smith.langchain.com/openai/v1 + ANTHROPIC_BASE_URL: https://gateway.smith.langchain.com/anthropic + GOOGLE_GEMINI_BASE_URL: https://gateway.smith.langchain.com/gemini + OPENAI_API_KEY: ${{ secrets.LANGSMITH_API_KEY_PROD }} + ANTHROPIC_API_KEY: ${{ secrets.LANGSMITH_API_KEY_PROD }} + GEMINI_API_KEY: ${{ secrets.LANGSMITH_API_KEY_PROD }} + GOOGLE_API_KEY: ${{ secrets.LANGSMITH_API_KEY_PROD }} + # Main pushes stop at the first failure; nightly and dispatch report them all. + JEST_BAIL: ${{ (github.event_name == 'push' || github.event.inputs.exit-first == 'true') && '--bail' || '' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Check credentials + run: '[ -n "$LANGSMITH_API_KEY" ] || { echo "::error::No API key secret for ${{ matrix.environment }}"; exit 1; }' + + - name: Run integration tests + run: pnpm test:integration $JEST_BAIL + + vitest-eval: + name: 'vitest eval${{ matrix.suffix }}' + needs: integration-matrix + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.integration-matrix.outputs.matrix) }} + env: + LANGSMITH_TRACING_V2: 'true' + LANGSMITH_ENDPOINT: ${{ matrix.endpoint }} + LANGSMITH_API_KEY: >- + ${{ (matrix.environment == 'v16-ch-only' && secrets.LANGSMITH_API_KEY_AKS_V15) + || (matrix.environment == 'v16-dual' && secrets.LANGSMITH_API_KEY_STABLE_DUAL) + || (matrix.environment == 'v16-smithdb-only' && secrets.LANGSMITH_API_KEY_STABLE_SMITHDB) + || secrets.LANGSMITH_API_KEY_BETA }} + VITEST_BAIL: ${{ (github.event_name == 'push' || github.event.inputs.exit-first == 'true') && '--bail=1' || '' }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '24' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Check credentials + run: '[ -n "$LANGSMITH_API_KEY" ] || { echo "::error::No API key secret for ${{ matrix.environment }}"; exit 1; }' + + - name: Run vitest eval runner + run: pnpm test:eval:vitest $VITEST_BAIL + + bun-test: + name: 'bun integration' + needs: integration-matrix + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + LANGSMITH_TRACING: 'true' + LANGSMITH_ENDPOINT: https://beta.api.smith.langchain.com + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY_BETA }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run Bun tests + run: bun test ./src/lib/tests/bun/traceable.test.bun.ts + + ci-success: + name: 'CI Success' + needs: [lint, test, build, exports, integration-matrix, integration, vitest-eval, bun-test] + if: always() + runs-on: ubuntu-latest + env: + RESULTS_JSON: ${{ toJSON(needs.*.result) }} + steps: + - run: | + echo "$RESULTS_JSON" + if echo "$RESULTS_JSON" | grep -qE '"(failure|cancelled)"'; then + echo "CI failed"; exit 1 + fi + + # Only the nightly posts: push and PR failures are visible to whoever pushed. + notify-slack-on-failure: + name: 'Notify Slack on nightly failure' + needs: [ci-success] + if: ${{ always() && github.repository == 'langchain-ai/langsmith-javascript-staging' && github.event_name == 'schedule' && needs.ci-success.result != 'success' }} + runs-on: ubuntu-latest + steps: + - uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.postMessage + token: ${{ secrets.INGESTION_CI_BOT_TOKEN }} + errors: true + payload: | + channel: ${{ toJSON(secrets.INGESTION_TEAM_SLACK_CHANNEL_ID) }} + text: | + *Nightly langsmith-javascript-staging CI failed* + *Run:* ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + *Environments:* beta, v16-ch-only, v16-dual, v16-smithdb-only diff --git a/.github/workflows/mirror-from-langsmith-sdk.yml b/.github/workflows/mirror-from-langsmith-sdk.yml new file mode 100644 index 0000000..d1d796a --- /dev/null +++ b/.github/workflows/mirror-from-langsmith-sdk.yml @@ -0,0 +1,216 @@ +name: Mirror from langsmith-sdk + +# Hourly: append every langsmith-sdk main commit after .mirror-source-sha that touches +# js/ to one rolling PR from mirror/langsmith-sdk, one commit per source commit +# (original author, pointer bumped in each). Never force-pushes an open PR, so fixes made +# by hand on the branch survive. Stops at the first conflict and labels the PR +# needs-manual-merge: resolve on the branch, remove the label, the next run appends the rest. +# Rebase-merge to keep the commits separate. Closing the PR discards it; the next run +# starts over from main. Skipped: langtions-bot and release(...) commits. Linear: LIN-504. + +on: + schedule: + - cron: '23 * * * *' + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: mirror-from-langsmith-sdk + cancel-in-progress: false + +env: + LANG_KEY: js + SOURCE_PATH: js + SOURCE_REPO: langchain-ai/langsmith-sdk + BRANCH: mirror/langsmith-sdk + +jobs: + mirror: + if: github.repository == 'langchain-ai/langsmith-javascript-staging' + runs-on: self-hosted-ubuntu-small-amd64 + steps: + - name: Load GitHub App key from 1Password + id: load-gh-app-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: false + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN_INTERNAL }} + OP_CONNECT_HOST: ${{ secrets.OP_CONNECT_HOST }} + LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY: op://${{ secrets.INTERNAL_INFRA_OP_VAULT }}/langchain-actions-pr-bot/private_key + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: 'Iv23liMFOFBcPTKFWi3S' + private-key: ${{ steps.load-gh-app-secrets.outputs.LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY }} + repositories: ${{ github.event.repository.name }} + + # the triggering ref supplies the script, so a dispatch from a branch tests that branch + - name: Check out staging + uses: actions/checkout@v7 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + + - name: Clone langsmith-sdk + run: git clone --quiet "https://github.com/$SOURCE_REPO.git" ../langsmith-sdk + + - name: Protect formatter dependency installs + uses: socketdev/action@ba6de6cc0565af1f42295590380973573297e31f + with: + mode: firewall-free + + - name: Set up Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Append new commits to the mirror branch + id: mirror + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + cp .github/scripts/mirror-from-langsmith-sdk.sh "$RUNNER_TEMP/mirror.sh" + cp .github/scripts/format-mirror.sh "$RUNNER_TEMP/format-mirror.sh" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + pr=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --base main --state open \ + --json number,labels --jq '.[0] // empty') + if [ -n "$pr" ]; then + if jq -e '.labels | map(.name) | index("needs-manual-merge")' <<<"$pr" >/dev/null; then + echo "Mirror PR waits for a manual merge. Remove the needs-manual-merge label once resolved." + exit 0 + fi + echo "pr=$(jq -r .number <<<"$pr")" >> "$GITHUB_OUTPUT" + git fetch --quiet origin "$BRANCH" + git checkout --quiet -B "$BRANCH" "origin/$BRANCH" + else + git checkout --quiet -B "$BRANCH" origin/main + fi + + staging=$PWD added=0 conflict= formatter_ready=false + pointer=$(cat .mirror-source-sha) + cd ../langsmith-sdk + for sha in $(git log --reverse --format=%H "$pointer..origin/main" -- "$SOURCE_PATH"); do + [ "$(git log -1 --format=%an "$sha")" = "langtions-bot[bot]" ] && continue + subject=$(git log -1 --format=%s "$sha") + case "$subject" in release\(*) continue ;; esac + # Generated-only commits come from the reverse sync. + if ! git diff --name-only "$sha^" "$sha" -- "$SOURCE_PATH" \ + | grep -v "^$SOURCE_PATH/src/_openapi_client/" >/dev/null; then continue; fi + out=$RUNNER_TEMP/mirror-$sha + "$RUNNER_TEMP/mirror.sh" "$LANG_KEY" "$sha" "$staging" "$out" + if [ "$(cat "$out/status")" = clean ]; then + if [ "$formatter_ready" = false ]; then + (cd "$staging" && pnpm install --frozen-lockfile --ignore-scripts) + formatter_ready=true + fi + "$RUNNER_TEMP/format-mirror.sh" "$staging" + fi + echo "$sha" > "$staging/.mirror-source-sha" + { + # Explicit URLs keep release-please from linking to production issue numbers. + printf '%s\n\n' "$(sed -E "s|\(#([0-9]+)\)$|([langsmith-sdk PR \1](https://github.com/$SOURCE_REPO/pull/\1))|" <<<"$subject")" + echo "Mirrored-From: $SOURCE_REPO@$sha" + sed 's/^/Mirror-Conflict: /' "$out/conflicts" + sed 's/^/Mirror-Manual-Port: /' "$out/manual" + sed 's/^/Mirror-Dropped: /' "$out/dropped" + } > "$out/msg" + git -C "$staging" add -A + git -C "$staging" commit --quiet --author="$(git log -1 --format='%an <%ae>' "$sha")" -F "$out/msg" + added=$((added + 1)) + echo "$(cat "$out/status") $sha $subject" + if [ "$(cat "$out/status")" = conflict ]; then conflict=$out; break; fi + done + cd "$staging" + + echo "added=$added" >> "$GITHUB_OUTPUT" + echo "conflict=$conflict" >> "$GITHUB_OUTPUT" + [ "$added" = 0 ] && { echo "Up to date with $SOURCE_REPO."; exit 0; } + # a stale branch from a merged or closed PR is safe to overwrite + if [ -n "$pr" ]; then git push --quiet origin "HEAD:refs/heads/$BRANCH" + else git push --quiet --force origin "HEAD:refs/heads/$BRANCH" + fi + + - name: Keep the rewritten patch of the conflicting commit + if: steps.mirror.outputs.conflict != '' + uses: actions/upload-artifact@v6 + with: + name: mirror-conflict + path: ${{ steps.mirror.outputs.conflict }} + + - name: Open or update the PR + id: pr + if: steps.mirror.outputs.added != '' && steps.mirror.outputs.added != '0' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PR: ${{ steps.mirror.outputs.pr }} + CONFLICT: ${{ steps.mirror.outputs.conflict }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + body=$RUNNER_TEMP/body.md + { + echo "Mirror of \`$SOURCE_REPO\` main. **Rebase-merge** to keep one commit per source commit. Closing discards it; the next run starts over from \`main\`." + echo + if [ -n "$CONFLICT" ]; then + echo "**Needs manual merge.** Resolve the markers on \`$BRANCH\`, push, then remove the \`needs-manual-merge\` label so the next run appends the rest. Patch: [run artifacts]($RUN_URL)." + echo + fi + for c in $(git log --reverse --format=%H origin/main..HEAD); do + src=$(git log -1 --format='%(trailers:key=Mirrored-From,valueonly)' "$c") + echo "- $(git log -1 --format=%s "$c")${src:+, $src}" + git log -1 --format='%(trailers:key=Mirror-Conflict,valueonly)' "$c" | sed '/^$/d; s/.*/ - conflict: `&`/' + git log -1 --format='%(trailers:key=Mirror-Manual-Port,valueonly)' "$c" | sed '/^$/d; s/.*/ - needs manual port: `&`/' + git log -1 --format='%(trailers:key=Mirror-Dropped,valueonly)' "$c" | sed '/^$/d; s/.*/ - dropped, generated client: `&`/' + done + } > "$body" + title="Mirror langsmith-sdk ($(git rev-list --count origin/main..HEAD) commits)" + + gh label create mirror --repo "$GITHUB_REPOSITORY" --color 0075ca --description "Mirrored from langsmith-sdk" --force + gh label create needs-manual-merge --repo "$GITHUB_REPOSITORY" --color d93f0b --description "Mirrored patch conflicted; resolve by hand" --force + # REST, not gh pr create/edit: their GraphQL label/reviewer mutations fail on this repo + api="repos/$GITHUB_REPOSITORY" + if [ -z "$PR" ]; then + PR=$(gh api -X POST "$api/pulls" -f base=main -f head="$BRANCH" -f title="$title" -F body=@"$body" --jq .number) + gh api -X POST "$api/pulls/$PR/requested_reviewers" -f 'reviewers[]=thibautlehmann' -f 'reviewers[]=KiewanVillatel' >/dev/null + else + gh api -X PATCH "$api/pulls/$PR" -f title="$title" -F body=@"$body" >/dev/null + fi + labels=(-f 'labels[]=mirror'); [ -n "$CONFLICT" ] && labels+=(-f 'labels[]=needs-manual-merge') + gh api -X POST "$api/issues/$PR/labels" "${labels[@]}" >/dev/null + echo "url=$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/pull/$PR" >> "$GITHUB_OUTPUT" + + - name: Notify Slack + if: steps.pr.outputs.url != '' + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.postMessage + token: ${{ secrets.INGESTION_CI_BOT_TOKEN }} + errors: true + payload: | + channel: ${{ toJSON(secrets.INGESTION_TEAM_SLACK_CHANNEL_ID) }} + text: | + <@U0BSQM2BQJ3> ${{ steps.mirror.outputs.added }} langsmith-sdk commit(s) added to the mirror PR${{ steps.mirror.outputs.conflict != '' && ', *needs manual merge*' || '' }}: ${{ steps.pr.outputs.url }} + + - name: Notify Slack on failure + if: failure() + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.postMessage + token: ${{ secrets.INGESTION_CI_BOT_TOKEN }} + errors: true + payload: | + channel: ${{ toJSON(secrets.INGESTION_TEAM_SLACK_CHANNEL_ID) }} + text: | + <@U0BSQM2BQJ3> mirror from langsmith-sdk failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 0000000..8d2b139 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,209 @@ +name: perf + +on: + pull_request: + paths: + - 'src/**' + - 'package.json' + - '.github/workflows/perf.yml' + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + runs-on: ubuntu-latest + steps: + - name: Checkout PR HEAD + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: pr + + - name: Checkout main + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + path: base + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: 24 + + - name: Set up pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '10.30.1' + + - name: Install deps (main) + working-directory: base + run: pnpm install --frozen-lockfile + + - name: Run benchmark (main) + working-directory: base + env: + LANGSMITH_RUN_PERF_BENCH: 'true' + LANGSMITH_TRACING: 'false' + LANGSMITH_PERF_BENCH_DIR: ${{ github.workspace }}/base-results + run: | + set -o pipefail + mkdir -p "$LANGSMITH_PERF_BENCH_DIR" + pnpm test:integration src/lib/tests/perf.int.test.ts \ + -t "benchmark event loop" 2>&1 | tee "$GITHUB_WORKSPACE/base-bench.log" + + - name: Install deps (PR) + working-directory: pr + run: pnpm install --frozen-lockfile + + - name: Run benchmark (PR) + working-directory: pr + env: + LANGSMITH_RUN_PERF_BENCH: 'true' + LANGSMITH_TRACING: 'false' + LANGSMITH_PERF_BENCH_DIR: ${{ github.workspace }}/pr-results + run: | + set -o pipefail + mkdir -p "$LANGSMITH_PERF_BENCH_DIR" + pnpm test:integration src/lib/tests/perf.int.test.ts \ + -t "benchmark event loop" 2>&1 | tee "$GITHUB_WORKSPACE/pr-bench.log" + + - name: Compare and post PR comment + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + with: + script: | + const fs = require("fs"); + const path = require("path"); + + const readJson = (dir, file) => { + try { + return JSON.parse( + fs.readFileSync(path.join(dir, file), "utf8") + ); + } catch { + return null; + } + }; + + const baseDir = path.join(process.env.GITHUB_WORKSPACE, "base-results"); + const prDir = path.join(process.env.GITHUB_WORKSPACE, "pr-results"); + + const cases = [ + { + title: "Base64-heavy payload", + description: + "Single large base64 string per message — the shape the worker-offload path is optimized for.", + file: "bench-base64.json", + }, + { + title: "Structural payload", + description: + "Many small strings across a wide/nested object graph. Should bypass worker offload and use sync flush.", + file: "bench-structural.json", + }, + ]; + + const fmt = (n) => (typeof n === "number" ? n.toFixed(2) : String(n)); + const delta = (a, b) => { + if (!a) return "n/a"; + const pct = ((b - a) / a) * 100; + const sign = pct >= 0 ? "+" : ""; + return `${sign}${pct.toFixed(1)}%`; + }; + + const metrics = [ + ["Wall time (ms)", ["wallMs"]], + ["createRun total (ms)", ["createRun", "total"]], + ["createRun p50 (ms)", ["createRun", "p50"]], + ["createRun p95 (ms)", ["createRun", "p95"]], + ["createRun p99 (ms)", ["createRun", "p99"]], + ["createRun max (ms)", ["createRun", "max"]], + ["updateRun total (ms)", ["updateRun", "total"]], + ["updateRun p95 (ms)", ["updateRun", "p95"]], + ["loop lag total (ms)", ["loopLag", "total"]], + ["loop lag p50 (ms)", ["loopLag", "p50"]], + ["loop lag p95 (ms)", ["loopLag", "p95"]], + ["loop lag p99 (ms)", ["loopLag", "p99"]], + ["loop lag max (ms)", ["loopLag", "max"]], + ]; + + const renderCase = ({ title, description, file }) => { + const base = readJson(baseDir, file); + const pr = readJson(prDir, file); + if (!base || !pr) { + return [ + `#### ${title}`, + ``, + `_Missing results_: base=${!!base}, pr=${!!pr}`, + ]; + } + const get = (obj, p) => + p.reduce((o, k) => (o ? o[k] : undefined), obj); + const rows = metrics.map(([label, p]) => { + const a = get(base, p); + const b = get(pr, p); + return `| ${label} | ${fmt(a)} | ${fmt(b)} | ${delta(a, b)} |`; + }); + return [ + `#### ${title}`, + ``, + description, + `Payload: ${(pr.inputBytes / 1024).toFixed(1)} KB in / ${( + pr.outputBytes / 1024 + ).toFixed(1)} KB out, ${pr.runs} runs.`, + ``, + `| metric | main | this PR | delta |`, + `| --- | ---: | ---: | ---: |`, + ...rows, + ``, + ]; + }; + + const body = [ + `### JS perf benchmark`, + ``, + `Lower is better. Noisy on shared runners — treat as a signal, not a gate.`, + ``, + ...cases.flatMap(renderCase), + ].join("\n"); + + // PR comments have a 65536-char limit; leave headroom. + const MAX = 60000; + const trimmed = + body.length > MAX ? body.slice(0, MAX) + "\n\n…truncated" : body; + + // Sticky upsert: find our previous comment by a hidden marker + // and update it in place, else create a new one. Avoids + // depending on a third-party sticky-comment action. + const MARKER = ""; + const message = `${MARKER}\n${trimmed}`; + + const pr_number = context.payload.pull_request?.number; + if (!pr_number) { + core.info("Not a pull_request event; skipping comment."); + return; + } + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + per_page: 100, + }); + const existing = comments.find((c) => c.body?.includes(MARKER)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body: message, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr_number, + body: message, + }); + } diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 0000000..23b095d --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,21 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'langchain-ai/langsmith-javascript' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + RELEASE_PLEASE_TOKEN: ${{ secrets.RELEASE_PLEASE_TOKEN }} diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..b80430e --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,20 @@ +name: Release Please +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + if: github.repository == 'langchain-ai/langsmith-javascript' + runs-on: ubuntu-latest + + steps: + - uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + id: release + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/stlc-promote-from-prod-to-main.yml b/.github/workflows/stlc-promote-from-prod-to-main.yml new file mode 100644 index 0000000..5da608d --- /dev/null +++ b/.github/workflows/stlc-promote-from-prod-to-main.yml @@ -0,0 +1,47 @@ +name: 'stlc: trigger promote on stlc/from-prod → main approval' + +# Dispatches the promote workflow in langchainplus whenever the stlc/from-prod → main +# PR in this repo receives an approving review. +on: + pull_request_review: + types: [submitted] + +permissions: + contents: read + +jobs: + trigger-promote: + if: | + github.repository == 'langchain-ai/langsmith-javascript-staging' && + github.event.review.state == 'approved' && + github.event.pull_request.base.ref == 'main' && + github.event.pull_request.head.ref == 'stlc/from-prod' + runs-on: self-hosted-ubuntu-small-amd64 + steps: + - name: Load GitHub App key from 1Password + id: load-gh-app-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: false + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN_INTERNAL }} + OP_CONNECT_HOST: ${{ secrets.OP_CONNECT_HOST }} + LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY: op://${{ secrets.INTERNAL_INFRA_OP_VAULT }}/langchain-actions-pr-bot/private_key + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: 'Iv23liMFOFBcPTKFWi3S' + private-key: ${{ steps.load-gh-app-secrets.outputs.LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY }} + repositories: langchainplus + + - name: Dispatch ff-only merge workflow in langchainplus + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run git-ff-only-merge.yml \ + --repo langchain-ai/langchainplus \ + --field repo=langsmith-javascript-staging \ + --field src_branch=stlc/from-prod \ + --field target_branch=main diff --git a/.github/workflows/stlc-promote-to-production.yml b/.github/workflows/stlc-promote-to-production.yml new file mode 100644 index 0000000..ffeba01 --- /dev/null +++ b/.github/workflows/stlc-promote-to-production.yml @@ -0,0 +1,91 @@ +name: 'stlc: promote to production' + +on: + push: + branches: [main] + +permissions: + contents: read + +jobs: + promote: + if: github.repository == 'langchain-ai/langsmith-javascript-staging' + runs-on: self-hosted-ubuntu-small-amd64 + env: + PRODUCTION_REPO: langchain-ai/langsmith-javascript + steps: + - name: Check out staging + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Load GitHub App key from 1Password + id: load-gh-app-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: false + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN_INTERNAL }} + OP_CONNECT_HOST: ${{ secrets.OP_CONNECT_HOST }} + LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY: op://${{ secrets.INTERNAL_INFRA_OP_VAULT }}/langchain-actions-pr-bot/private_key + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: 'Iv23liMFOFBcPTKFWi3S' + private-key: ${{ steps.load-gh-app-secrets.outputs.LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY }} + repositories: langsmith-javascript-staging,langsmith-javascript + + - name: Fetch production main + run: | + git remote add production \ + "https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/${PRODUCTION_REPO}.git" + git fetch production main + + - name: Check if production is already in sync + id: diff + run: | + STAGING_SHA=$(git rev-parse origin/main) + PRODUCTION_SHA=$(git rev-parse production/main) + if [ "$STAGING_SHA" = "$PRODUCTION_SHA" ]; then + echo "Production is already at $STAGING_SHA. Nothing to release." + echo "synced=true" >> "$GITHUB_OUTPUT" + else + echo "synced=false" >> "$GITHUB_OUTPUT" + fi + + - name: Push staging main to the release branch on production + if: steps.diff.outputs.synced == 'false' + run: | + git push production origin/main:refs/heads/stainless/release --force + + - name: Open or update the release PR on production + if: steps.diff.outputs.synced == 'false' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + COMMITS=$(git log --reverse --format="%s%n" production/main..origin/main) + REVIEW_NOTE=$'> [!NOTE]\n> These changes are either auto-generated or already reviewed, so only a very light review is required: guard against a potential catastrophic generation failure by checking the diff stat for an unexpectedly large number of deleted files, and confirm any deletion traces back to a legitimate commit (e.g. an endpoint removal).\n> See [Releasing the SDKs](https://github.com/langchain-ai/langchainplus/tree/main/smith-sdks#releasing-the-sdks) for the full instructions.' + BODY="$(printf '%s\n\n%s\n' "${COMMITS}" "${REVIEW_NOTE}")" + + EXISTING_PR=$(gh pr list \ + --repo "${PRODUCTION_REPO}" \ + --head stainless/release \ + --state open \ + --json number \ + --jq '.[0].number') + if [ -z "${EXISTING_PR}" ]; then + gh pr create \ + --repo "${PRODUCTION_REPO}" \ + --base main \ + --head stainless/release \ + --title "Release SDK updates" \ + --body "${BODY}" + else + gh pr edit "${EXISTING_PR}" \ + --repo "${PRODUCTION_REPO}" \ + --body "${BODY}" + echo "Release PR #${EXISTING_PR} already exists. Updated its description." + fi diff --git a/.github/workflows/stlc-seal-custom-code.yml b/.github/workflows/stlc-seal-custom-code.yml new file mode 100644 index 0000000..80cfacd --- /dev/null +++ b/.github/workflows/stlc-seal-custom-code.yml @@ -0,0 +1,47 @@ +name: 'stlc: seal custom code' + +# Triggered on every push to the `main` branch of langsmith-javascript-staging. +# Delegates to the stlc_seal_custom_code workflow in langchainplus (where +# the stainless workspace and reusable actions live) by dispatching it via +# the GitHub API, scoped to the typescript target only. +on: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + +permissions: + id-token: write + +jobs: + seal: + if: github.repository == 'langchain-ai/langsmith-javascript-staging' + runs-on: self-hosted-ubuntu-small-amd64 + steps: + - name: Load GitHub App key from 1Password + id: load-gh-app-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: false + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN_INTERNAL }} + OP_CONNECT_HOST: ${{ secrets.OP_CONNECT_HOST }} + LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY: op://${{ secrets.INTERNAL_INFRA_OP_VAULT }}/langchain-actions-pr-bot/private_key + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: 'Iv23liMFOFBcPTKFWi3S' + private-key: ${{ steps.load-gh-app-secrets.outputs.LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY }} + repositories: langsmith-javascript-staging,langchainplus + + - name: Trigger stlc seal workflow in langchainplus + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + gh workflow run stlc_seal_custom_code.yml \ + --repo langchain-ai/langchainplus \ + --field repositories=langsmith-javascript-staging diff --git a/.github/workflows/stlc-sync-from-production.yml b/.github/workflows/stlc-sync-from-production.yml new file mode 100644 index 0000000..17ceedb --- /dev/null +++ b/.github/workflows/stlc-sync-from-production.yml @@ -0,0 +1,86 @@ +name: 'stlc: sync from production' + +on: + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: {} + +permissions: + contents: write + pull-requests: write + +concurrency: + group: stlc-sync-from-production + cancel-in-progress: true + +jobs: + sync: + runs-on: self-hosted-ubuntu-small-amd64 + if: github.repository == 'langchain-ai/langsmith-javascript-staging' + env: + PRODUCTION_REPO: langchain-ai/langsmith-javascript + steps: + - name: Load GitHub App key from 1Password + id: load-gh-app-secrets + uses: 1password/load-secrets-action@3a12b0ab99d9cd590a3e9b5a90ea017210ed9556 # v4.0.1 + with: + export-env: false + env: + OP_CONNECT_TOKEN: ${{ secrets.OP_CONNECT_TOKEN_INTERNAL }} + OP_CONNECT_HOST: ${{ secrets.OP_CONNECT_HOST }} + LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY: op://${{ secrets.INTERNAL_INFRA_OP_VAULT }}/langchain-actions-pr-bot/private_key + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v3 + with: + app-id: 'Iv23liMFOFBcPTKFWi3S' + private-key: ${{ steps.load-gh-app-secrets.outputs.LANGCHAINPLUS_GITHUB_APP_PRIVATE_KEY }} + repositories: langsmith-javascript-staging,langsmith-javascript,stlc,stlc-typescript,langchainplus + + - name: Check out staging + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: main + token: ${{ steps.app-token.outputs.token }} + + - name: Fetch production main + env: + APP_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + git remote add production \ + "https://x-access-token:${APP_TOKEN}@github.com/${PRODUCTION_REPO}.git" + git fetch production main + + - name: Check whether production has content staging lacks + id: diff + run: | + MERGED=$(git merge-tree --write-tree origin/main production/main) || MERGED=conflict + STAGING_TREE=$(git rev-parse 'origin/main^{tree}') + if [ "$MERGED" = "$STAGING_TREE" ]; then + echo "Staging already has production's content. Nothing to pull back." + echo "behind=false" >> "$GITHUB_OUTPUT" + else + echo "behind=true" >> "$GITHUB_OUTPUT" + fi + + - name: Push production main to the back-sync branch on staging + if: steps.diff.outputs.behind == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: git push origin production/main:refs/heads/stlc/from-prod --force + + - name: Open or update the back-sync PR on staging + if: steps.diff.outputs.behind == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + EXISTING_PR=$(gh pr list --head stlc/from-prod --base main --state open --json number --jq '.[0].number') + if [ -z "${EXISTING_PR}" ]; then + gh pr create \ + --base main \ + --head stlc/from-prod \ + --title "Sync release changes from production" \ + --body "Pulls production's release-please commits (and any merged contributions) back onto staging \`main\` so the next \`stlc build\` reseals against the released state. Bot-authored; safe to auto-merge." + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bab664 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.prism.log +.stdy.log +node_modules +yarn-error.log +codegen.log +Brewfile.lock.json +dist +dist-deno +/*.tgz +.idea/ +.eslintcache + +.env +_build +.attw.json diff --git a/.mirror-source-sha b/.mirror-source-sha new file mode 100644 index 0000000..410cf61 --- /dev/null +++ b/.mirror-source-sha @@ -0,0 +1 @@ +842d056d001e9bc7faf543da89278613dfebc284 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b436990 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,17 @@ +CHANGELOG.md +/ecosystem-tests/*/** +/node_modules +/deno + +# don't format tsc output, will break source maps +/dist + +# vendored / fixtures (hand-written SDK) +/src/lib/utils/chalk +/src/lib/utils/console-table-printer +/src/lib/utils/simple-wcswidth +/src/lib/tests/test_data +/_build + +# standalone pnpm projects with their own lockfiles +/internal/environment_tests diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e7be831 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,17 @@ +{ + "arrowParens": "always", + "experimentalTernaries": true, + "printWidth": 110, + "singleQuote": true, + "trailingComma": "all", + "overrides": [ + { + "files": ["src/lib/**", "vitest.config.ts", "ls.vitest.config.ts"], + "options": { + "singleQuote": false, + "printWidth": 80, + "experimentalTernaries": false + } + } + ] +} diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e1206f4 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.10.4" +} diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 0000000..6211a37 --- /dev/null +++ b/.stats.yml @@ -0,0 +1 @@ +configured_endpoints: 81 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..69a3106 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,58 @@ +# Instructions for code modifications in the JS/TS SDK + +This repo holds the LangSmith JavaScript/TypeScript SDK: the hand-written client (tracing, run trees, evaluation, sandbox, wrappers, jest/vitest helpers) in `src/lib/`, on top of the Stainless-generated REST client in `src/`. + +## External contributor PRs + +Do not authorize GitHub Actions on external contributors' PRs. +Follow [Taking over an external contributor's PR](CONTRIBUTING.md#taking-over-an-external-contributors-pr) when taking over one. +Review before pushing, cherry-pick reviewed commits onto an internal branch, preserve contributor authorship, and open a replacement PR. + +## Before opening a PR + +Run from the repo root: + +``` +pnpm format +pnpm lint +pnpm test +``` + +`pnpm test` runs two jest projects: `stainless` (generated SDK) and `lib` (hand-written SDK, ESM). To run one hand-written test file: + +```bash +NODE_OPTIONS=--experimental-vm-modules npx jest --selectProjects lib src/lib/tests/context.test.ts +``` + +Integration tests (`pnpm test:integration`) and vitest suites (`pnpm test:vitest`, `pnpm test:eval:vitest`) need API keys and run in CI. Prefer CI over local runs when in doubt: shell or direnv `LANGSMITH_*` variables and `~/.langsmith` config leak in and cause spurious local failures. + +## Generated files + +Files whose first line carries `File generated from our OpenAPI spec by Stainless.` are regenerated on every sync. Do not edit them. Change the OpenAPI spec or `stainless.yml` in `langchain-ai/langchainplus` instead. CI blocks PRs that touch them unless the PR has the `skip-stainless-generated-file-modification-check` label. + +Hand-written code is sealed as custom code by the `stlc-seal-custom-code` workflow on every push to `main`, so it survives regeneration. + +Entry points such as `langsmith/traceable` or `langsmith/wrappers/openai` are one-line shims at `src/.ts` pointing into `src/lib/`. Add a shim and a `package.json` `exports` entry when adding a new public entry point. + +## Releasing + +Releases are cut by release-please from conventional commits on `main`. Do not hand-edit the version in `package.json`, `src/version.ts` or `.release-please-manifest.json`, and do not push tags. + +## Conventions + +### Constructing request URLs + +Do not hardcode a leading `/v1` (or other `apiUrl`-dependent prefix) into request +paths. `LANGSMITH_ENDPOINT` may already include `/api/v1`, so a hardcoded `/v1/...` +produces a duplicated `/api/v1/v1/...` path and 404s. + +For platform endpoints, build the path with the existing `_getPlatformEndpointPath` +helper, which only adds the `/v1` prefix when the configured `apiUrl` does not +already end in `/v1`: + +```ts +`${this.apiUrl}${this._getPlatformEndpointPath(`hub/repos/${owner}/${name}/directories`)}`; +``` + +When adding a new platform endpoint, follow the same pattern instead of inlining +`/v1/platform/...`. diff --git a/Brewfile b/Brewfile new file mode 100644 index 0000000..e4feee6 --- /dev/null +++ b/Brewfile @@ -0,0 +1 @@ +brew "node" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..635913b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,124 @@ +## Taking over an external contributor's PR + +Do not authorize GitHub Actions on external contributors' PRs. +Review the contribution, then cherry-pick reviewed commits onto an internal branch in `langchain-ai/langsmith-javascript-staging`. +Open a maintainer-owned replacement PR so CI can run there. + +1. Review the original PR's complete diff and commit list before pushing. + Include code, tests, dependencies, scripts and workflows that CI would execute. + Moving code to an internal branch does not make it safe. +2. Start from the latest target branch in a clean checkout. + Fetch the original PR's head and cherry-pick only reviewed commits, oldest first. + Replace `123` with the original PR number and `main` with its target branch. + Set the SHA variables to reviewed original commits; omit the second if unnecessary. + + ```bash + git fetch origin + git switch -c thibautlehmann/external-pr-123 origin/main + git fetch origin pull/123/head + git cherry-pick -x "$reviewed_commit_sha" "$next_reviewed_commit_sha" + ``` + + Use explicit commit SHAs, never a moving branch head. + `-x` records the source commit; cherry-picking preserves the contributor's authorship. + Resolve conflicts and review the resulting diff before continuing, or run `git cherry-pick --abort`. + +3. Review the final diff against the target branch and run the relevant local checks. + Push to this repository's `origin`, then open a replacement PR against the original target branch. + Link the original PR, credit the contributor and explain changes made during the takeover. + Agents should use their PR-creation tool when available. +4. Run CI and obtain the usual review on the replacement PR. + Do not approve the original workflows or weaken CI security settings. + Link the replacement from the original PR. + Close the original as superseded once the replacement merges. + +Review and cherry-pick any later contributor commits explicitly. +Do not automatically sync unreviewed updates. + +## Setting up the environment + +This repository uses [`pnpm`](https://pnpm.io/). +Other package managers may work but are not officially supported for development. + +To set up the repository, run: + +```sh +$ pnpm install +$ pnpm build +``` + +This will install all the required dependencies and build output files to `dist/`. + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```ts +// add an example to examples/.ts + +#!/usr/bin/env -S npm run tsn -T +… +``` + +```sh +$ chmod +x examples/.ts +# run the example against your api +$ pnpm tsn -T examples/.ts +``` + +## Using the repository from source + +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: + +To install via git: + +```sh +$ npm install git+ssh://git@github.com:langchain-ai/langsmith-javascript.git +``` + +Alternatively, to link a local copy of the repo: + +```sh +# Clone +$ git clone https://www.github.com/langchain-ai/langsmith-javascript +$ cd langsmith-javascript + +# With yarn +$ yarn link +$ cd ../my-package +$ yarn link langsmith + +# With pnpm +$ pnpm link --global +$ cd ../my-package +$ pnpm link --global langsmith +``` + +## Running tests + +```sh +$ pnpm run test +``` + +## Linting and formatting + +This repository uses [prettier](https://www.npmjs.com/package/prettier) and +[eslint](https://www.npmjs.com/package/eslint) to format the code in the repository. + +To lint: + +```sh +$ pnpm lint +``` + +To format and fix all lint issues automatically: + +```sh +$ pnpm fix +``` diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..09a1b8b --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright 2026 langChain + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e5fff89 --- /dev/null +++ b/README.md @@ -0,0 +1,1068 @@ +# LangSmith Client SDK + +![NPM Version](https://img.shields.io/npm/v/langsmith?logo=npm) +[![JS Downloads](https://img.shields.io/npm/dm/langsmith)](https://www.npmjs.com/package/langsmith) + +This package contains the TypeScript client for interacting with the [LangSmith platform](https://smith.langchain.com/). + +To install: + +```bash +pnpm add langsmith +``` + +LangSmith helps you and your team develop and evaluate language models and intelligent agents. It is compatible with any LLM Application and provides seamless integration with [LangChain](https://github.com/hwchase17/langchainjs), a widely recognized open-source framework that simplifies the process for developers to create powerful language model applications. + +> **Note**: You can enjoy the benefits of LangSmith without using the LangChain open-source packages! To get started with your own proprietary framework, set up your account and then skip to [Logging Traces Outside LangChain](#logging-traces-outside-langchain). + +> **Cookbook:** For tutorials on how to get more value out of LangSmith, check out the [Langsmith Cookbook](https://github.com/langchain-ai/langsmith-cookbook/tree/main) repo. + +A typical workflow looks like: + +1. Set up an account with LangSmith. +2. Log traces. +3. Debug, Create Datasets, and Evaluate Runs. + +We'll walk through these steps in more detail below. + +## Sandbox AWS Auth Proxy + +When sandbox code needs to call AWS services, use the sandbox AWS auth proxy. +The proxy keeps the real AWS credentials outside the sandbox and signs supported +AWS HTTPS requests with SigV4, so code in the sandbox can use AWS SDKs normally +without storing long-lived AWS keys in files, environment variables, shell +history, or logs. + +Store AWS credentials as LangSmith workspace secrets using names that make sense +for your workspace. Then create the sandbox with an AWS auth proxy config: + +```ts +import { SandboxClient, awsAuth, proxyConfig, workspaceSecret } from 'langsmith/sandbox'; + +const client = new SandboxClient(); +const authConfig = proxyConfig({ + rules: [ + awsAuth({ + accessKeyId: workspaceSecret('SANDBOX_AWS_ACCESS_KEY_ID'), + secretAccessKey: workspaceSecret('SANDBOX_AWS_SECRET_ACCESS_KEY'), + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: 'aws-sandbox', + proxyConfig: authConfig, +}); + +try { + const result = await sandbox.run('node your-aws-script.js'); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Use `opaqueSecret("...")` instead of `workspaceSecret(...)` when your application +needs to pass short-lived write-only AWS credentials at sandbox creation time. +Plaintext AWS credential values are not accepted directly; wrap them as +`opaqueSecret(...)` values. + +## Sandbox GCP Auth Proxy + +When sandbox code needs to call Google APIs, use the sandbox GCP auth proxy. +The proxy keeps the service account JSON outside the sandbox and injects OAuth +bearer tokens for Google API hosts matched automatically by the sandbox proxy. + +Store the service account JSON as a LangSmith workspace secret. Then create the +sandbox with a GCP auth proxy config: + +```ts +import { SandboxClient, gcpAuth, proxyConfig, workspaceSecret } from 'langsmith/sandbox'; + +const client = new SandboxClient(); +const authConfig = proxyConfig({ + rules: [ + gcpAuth({ + serviceAccountJson: workspaceSecret('SANDBOX_GCP_SERVICE_ACCOUNT_JSON'), + scopes: ['https://www.googleapis.com/auth/devstorage.read_write'], + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: 'gcp-sandbox', + proxyConfig: authConfig, +}); + +try { + const result = await sandbox.run('node your-gcp-script.js'); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Use `opaqueSecret("...")` for short-lived write-only service account JSON. +Plaintext service account JSON is not accepted directly. + +## Sandbox Mounts + +When you create a LangSmith sandbox that needs filesystem access to external +data such as object storage buckets or public Git repositories, pass a +`mountConfig` on sandbox creation. Mount specs contain only the mount target. +Provider credentials stay in `mountConfig.auth`; the backend expands them into +runtime proxy auth rules. You can also pass `proxyConfig` for non-mount proxy +behavior such as custom headers, callbacks, access control, and generic egress +rules. Explicit AWS/GCP proxy auth rules conflict with `mountConfig` auth for +the same provider. + +S3 mounts require AWS auth: + +```ts +import { awsAuth, mountConfig, s3Mount, workspaceSecret } from 'langsmith/sandbox'; + +const mountCfg = mountConfig({ + auth: [ + awsAuth({ + accessKeyId: workspaceSecret('SANDBOX_AWS_ACCESS_KEY_ID'), + secretAccessKey: workspaceSecret('SANDBOX_AWS_SECRET_ACCESS_KEY'), + }), + ], + mounts: [ + s3Mount({ + id: 'customer_data', + mountPath: '/mnt/mounts/customer-data', + bucket: 'example-bucket', + prefix: 'datasets/customer-data', + region: 'us-east-1', + endpointUrl: 'https://s3.amazonaws.com', + pathStyle: false, + readOnly: false, + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: 's3-mount-sandbox', + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run('ls /mnt/mounts/customer-data'); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +GCS mounts require GCP auth: + +```ts +import { gcpAuth, gcsMount, mountConfig, workspaceSecret } from 'langsmith/sandbox'; + +const mountCfg = mountConfig({ + auth: [ + gcpAuth({ + serviceAccountJson: workspaceSecret('SANDBOX_GCP_SERVICE_ACCOUNT_JSON'), + }), + ], + mounts: [ + gcsMount({ + id: 'customer_data', + mountPath: '/mnt/mounts/customer-data', + bucket: 'example-bucket', + prefix: 'datasets/customer-data', + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: 'gcs-mount-sandbox', + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run('ls /mnt/mounts/customer-data'); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Public Git mounts do not require AWS or GCP auth: + +```ts +import { gitMount, mountConfig } from 'langsmith/sandbox'; + +const mountCfg = mountConfig({ + mounts: [ + gitMount({ + id: 'repo', + mountPath: '/mnt/repo', + remoteUrl: 'https://github.com/langchain-ai/langsmith-javascript.git', + ref: { type: 'branch', name: 'main' }, + refreshIntervalSeconds: 60, + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: 'git-mount-sandbox', + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run('ls /mnt/repo'); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Private Git repositories can use low-level `proxyConfig` rules when the remote +requires proxy-managed auth. There is not yet a high-level private Git auth +helper. + +## 1. Connect to LangSmith + +Sign up for [LangSmith](https://smith.langchain.com/) using your GitHub, Discord accounts, or an email address and password. If you sign up with an email, make sure to verify your email address before logging in. + +Then, create a unique API key on the [Settings Page](https://smith.langchain.com/settings). + +> [!NOTE] +> Save the API Key in a secure location. It will not be shown again. + +## 2. Log Traces + +You can log traces natively in your LangChain application or using a LangSmith RunTree. + +### Logging Traces with LangChain + +LangSmith seamlessly integrates with the JavaScript LangChain library to record traces from your LLM applications. + +```bash +pnpm add langchain +``` + +1. **Copy the environment variables from the Settings Page and add them to your application.** + +Tracing can be activated by setting the following environment variables or by manually specifying the LangChainTracer. + +```typescript +process.env.LANGSMITH_TRACING = 'true'; +process.env.LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com'; +// process.env.LANGSMITH_ENDPOINT = "https://eu.api.smith.langchain.com"; // If signed up in the EU region +process.env.LANGSMITH_API_KEY = ''; +// process.env.LANGSMITH_PROJECT = "My Project Name"; // Optional: "default" is used if not set +// process.env.LANGSMITH_WORKSPACE_ID = ""; // Required for org-scoped API keys +``` + +> **Tip:** Projects are groups of traces. All runs are logged to a project. If not specified, the project is set to `default`. + +2. **Run an Agent, Chain, or Language Model in LangChain** + +If the environment variables are correctly set, your application will automatically connect to the LangSmith platform. + +```typescript +import { ChatOpenAI } from 'langchain/chat_models/openai'; + +const chat = new ChatOpenAI({ temperature: 0 }); +const response = await chat.predict('Translate this sentence from English to French. I love programming.'); +console.log(response); +``` + +### Logging Traces Outside LangChain + +You can still use the LangSmith development platform without depending on any +LangChain code. You can connect either by setting the appropriate environment variables, +or by directly specifying the connection information in the RunTree. + +1. **Copy the environment variables from the Settings Page and add them to your application.** + +```shell +export LANGSMITH_TRACING="true"; +export LANGSMITH_API_KEY= +# export LANGSMITH_PROJECT="My Project Name" # Optional: "default" is used if not set +# export LANGSMITH_ENDPOINT=https://api.smith.langchain.com # or your own server +``` + +## Integrations + +Langsmith's `traceable` wrapper function makes it easy to trace any function or LLM call in your own favorite framework. Below are some examples. + +### OpenAI SDK + + + +The easiest way to trace calls from the [OpenAI SDK](https://platform.openai.com/docs/api-reference) with LangSmith +is using the `wrapOpenAI` wrapper function available in LangSmith 0.1.3 and up. + +In order to use, you first need to set your LangSmith API key: + +```shell +export LANGSMITH_TRACING="true"; +export LANGSMITH_API_KEY= +``` + +Next, you will need to install the LangSmith SDK and the OpenAI SDK: + +```shell +npm install langsmith openai +``` + +After that, initialize your OpenAI client and wrap the client with `wrapOpenAI` method to enable tracing for the completions and chat completions methods: + +```ts +import { OpenAI } from 'openai'; +import { wrapOpenAI } from 'langsmith/wrappers'; + +const openai = wrapOpenAI(new OpenAI()); + +await openai.chat.completions.create({ + model: 'gpt-5.4', + messages: [{ content: 'Hi there!', role: 'user' }], +}); +``` + +Alternatively, you can use the `traceable` function to wrap the client methods you want to use: + +```ts +import { traceable } from 'langsmith/traceable'; + +const openai = new OpenAI(); + +const createCompletion = traceable(openai.chat.completions.create.bind(openai.chat.completions), { + name: 'OpenAI Chat Completion', + run_type: 'llm', +}); + +await createCompletion({ + model: 'gpt-5.4', + messages: [{ content: 'Hi there!', role: 'user' }], +}); +``` + +Note the use of `.bind` to preserve the function's context. The `run_type` field in the +extra config object marks the function as an LLM call, and enables token usage tracking +for OpenAI. + +Oftentimes, you use the OpenAI client inside of other functions or as part of a longer +sequence. You can automatically get nested traces by using this wrapped method +within other functions wrapped with `traceable`. + +```ts +const nestedTrace = traceable(async (text: string) => { + const completion = await openai.chat.completions.create({ + model: 'gpt-5.4', + messages: [{ content: text, role: 'user' }], + }); + return completion; +}); + +await nestedTrace('Why is the sky blue?'); +``` + +``` +{ + "id": "chatcmpl-8sPToJQLLVepJvyeTfzZMOMVIKjMo", + "object": "chat.completion", + "created": 1707978348, + "model": "gpt-5.4", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The sky appears blue because of a phenomenon known as Rayleigh scattering. The Earth's atmosphere is composed of tiny molecules, such as nitrogen and oxygen, which are much smaller than the wavelength of visible light. When sunlight interacts with these molecules, it gets scattered in all directions. However, shorter wavelengths of light (blue and violet) are scattered more compared to longer wavelengths (red, orange, and yellow). \n\nAs a result, when sunlight passes through the Earth's atmosphere, the blue and violet wavelengths are scattered in all directions, making the sky appear blue. This scattering of shorter wavelengths is also responsible for the vibrant colors observed during sunrise and sunset, when the sunlight has to pass through a thicker portion of the atmosphere, causing the longer wavelengths to dominate the scattered light." + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 13, + "completion_tokens": 154, + "total_tokens": 167 + }, + "system_fingerprint": null +} +``` + +:::tip +[Click here](https://smith.langchain.com/public/4af46ef6-b065-46dc-9cf0-70f1274edb01/r) to see an example LangSmith trace of the above. +::: + +## Next.js + +You can use the `traceable` wrapper function in Next.js apps to wrap arbitrary functions much like in the example above. + +One neat trick you can use for Next.js and other similar server frameworks is to wrap the entire exported handler for a route +to group traces for the any sub-runs. Here's an example: + +```ts +import { NextRequest, NextResponse } from 'next/server'; + +import { OpenAI } from 'openai'; +import { traceable } from 'langsmith/traceable'; +import { wrapOpenAI } from 'langsmith/wrappers'; + +export const runtime = 'edge'; + +const handler = traceable( + async function () { + const openai = wrapOpenAI(new OpenAI()); + + const completion = await openai.chat.completions.create({ + model: 'gpt-5.4', + messages: [{ content: 'Why is the sky blue?', role: 'user' }], + }); + + const response1 = completion.choices[0].message.content; + + const completion2 = await openai.chat.completions.create({ + model: 'gpt-5.4', + messages: [ + { content: 'Why is the sky blue?', role: 'user' }, + { content: response1, role: 'assistant' }, + { content: 'Cool thank you!', role: 'user' }, + ], + }); + + const response2 = completion2.choices[0].message.content; + + return { + text: response2, + }; + }, + { + name: 'Simple Next.js handler', + }, +); + +export async function POST(req: NextRequest) { + const result = await handler(); + return NextResponse.json(result); +} +``` + +The two OpenAI calls within the handler will be traced with appropriate inputs, outputs, +and token usage information. + +:::tip +[Click here](https://smith.langchain.com/public/faaf26ad-8c59-4622-bcfe-b7d896733ca6/r) to see an example LangSmith trace of the above. +::: + +## Vercel AI SDK + +The [Vercel AI SDK](https://sdk.vercel.ai/docs) contains integrations with a variety of model providers. +Here's an example of how you can trace outputs in a Next.js handler: + +```ts +import { traceable } from 'langsmith/traceable'; +import { OpenAIStream, StreamingTextResponse } from 'ai'; + +// Note: There are no types for the Mistral API client yet. +import MistralClient from '@mistralai/mistralai'; + +const client = new MistralClient(process.env.MISTRAL_API_KEY || ''); + +export async function POST(req: Request) { + // Extract the `messages` from the body of the request + const { messages } = await req.json(); + + const mistralChatStream = traceable(client.chatStream.bind(client), { + name: 'Mistral Stream', + run_type: 'llm', + }); + + const response = await mistralChatStream({ + model: 'mistral-tiny', + maxTokens: 1000, + messages, + }); + + // Convert the response into a friendly text-stream. The Mistral client responses are + // compatible with the Vercel AI SDK OpenAIStream adapter. + const stream = OpenAIStream(response as any); + + // Respond with the stream + return new StreamingTextResponse(stream); +} +``` + +See the [AI SDK docs](https://sdk.vercel.ai/docs) for more examples. + +## Arbitrary SDKs + +You can use the generic `wrapSDK` method to add tracing for arbitrary SDKs. + +Do note that this will trace ALL methods in the SDK, not just chat completion endpoints. +If the SDK you are wrapping has other methods, we recommend using it for only LLM calls. + +Here's an example using the Anthropic SDK: + +```ts +import { wrapSDK } from 'langsmith/wrappers'; +import { Anthropic } from '@anthropic-ai/sdk'; + +const originalSDK = new Anthropic(); +const sdkWithTracing = wrapSDK(originalSDK); + +const response = await sdkWithTracing.messages.create({ + messages: [ + { + role: 'user', + content: `What is 1 + 1? Respond only with "2" and nothing else.`, + }, + ], + model: 'claude-3-sonnet-20240229', + max_tokens: 1024, +}); +``` + +:::tip +[Click here](https://smith.langchain.com/public/0e7248af-bbed-47cf-be9f-5967fea1dec1/r) to see an example LangSmith trace of the above. +::: + +#### Alternatives: **Log traces using a RunTree.** + +A RunTree tracks your application. Each RunTree object is required to have a name and run_type. These and other important attributes are as follows: + +- `name`: `string` - used to identify the component's purpose +- `run_type`: `string` - Currently one of "llm", "chain" or "tool"; more options will be added in the future +- `inputs`: `Record` - the inputs to the component +- `outputs`: `Optional>` - the (optional) returned values from the component +- `error`: `Optional` - Any error messages that may have arisen during the call + +```typescript +import { RunTree, RunTreeConfig } from 'langsmith'; + +const parentRunConfig: RunTreeConfig = { + name: 'My Chat Bot', + run_type: 'chain', + inputs: { + text: "Summarize this morning's meetings.", + }, + serialized: {}, // Serialized representation of this chain + // project_name: "Defaults to the LANGSMITH_PROJECT env var" + // apiUrl: "Defaults to the LANGSMITH_ENDPOINT env var" + // apiKey: "Defaults to the LANGSMITH_API_KEY env var" +}; + +const parentRun = new RunTree(parentRunConfig); + +await parentRun.postRun(); + +const childLlmRun = await parentRun.createChild({ + name: 'My Proprietary LLM', + run_type: 'llm', + inputs: { + prompts: ['You are an AI Assistant. The time is XYZ.' + " Summarize this morning's meetings."], + }, +}); + +await childLlmRun.postRun(); + +await childLlmRun.end({ + outputs: { + generations: ['I should use the transcript_loader tool' + ' to fetch meeting_transcripts from XYZ'], + }, +}); + +await childLlmRun.patchRun(); + +const childToolRun = await parentRun.createChild({ + name: 'transcript_loader', + run_type: 'tool', + inputs: { + date: 'XYZ', + content_type: 'meeting_transcripts', + }, +}); +await childToolRun.postRun(); + +await childToolRun.end({ + outputs: { + meetings: ['Meeting1 notes..'], + }, +}); + +await childToolRun.patchRun(); + +const childChainRun = await parentRun.createChild({ + name: 'Unreliable Component', + run_type: 'tool', + inputs: { + input: 'Summarize these notes...', + }, +}); + +await childChainRun.postRun(); + +try { + // .... the component does work + throw new Error('Something went wrong'); +} catch (e) { + await childChainRun.end({ + error: `I errored again ${e.message}`, + }); + await childChainRun.patchRun(); + throw e; +} + +await childChainRun.patchRun(); + +await parentRun.end({ + outputs: { + output: ['The meeting notes are as follows:...'], + }, +}); + +// False directs to not exclude child runs +await parentRun.patchRun(); +``` + +## Evaluation + +#### Create a Dataset from Existing Runs + +Once your runs are stored in LangSmith, you can convert them into a dataset. +For this example, we will do so using the Client, but you can also do this using +the web interface, as explained in the [LangSmith docs](https://docs.smith.langchain.com/docs/). + +```typescript +import { Client } from 'langsmith/client'; +const client = new Client({ + // apiUrl: "https://api.langchain.com", // Defaults to the LANGSMITH_ENDPOINT env var + // apiKey: "my_api_key", // Defaults to the LANGSMITH_API_KEY env var + /* callerOptions: { + maxConcurrency?: Infinity; // Maximum number of concurrent requests to make + maxRetries?: 6; // Maximum number of retries to make + */ +}); +const datasetName = 'Example Dataset'; +// We will only use examples from the top level AgentExecutor run here, +// and exclude runs that errored. +const runs = await client.listRuns({ + projectName: 'my_project', + executionOrder: 1, + error: false, +}); + +const dataset = await client.createDataset(datasetName, { + description: 'An example dataset', +}); + +for (const run of runs) { + await client.createExample(run.inputs, run.outputs ?? {}, { + datasetId: dataset.id, + }); +} +``` + +## Additional Documentation + +To learn more about the LangSmith platform, check out the [docs](https://docs.smith.langchain.com/docs/). + +# Generated REST client + +The low-level, generated client (`Langsmith`) ships in the same package. Everything below is maintained by Stainless. + +## Installation + +```sh +npm install git+ssh://git@github.com:langchain-ai/langsmith-javascript.git +``` + +> [!NOTE] +> Once this package is [published to npm](https://www.stainless.com/docs/guides/publish), this will become: `npm install langsmith` + +## Usage + +The full API of this library can be found in [api.md](api.md). + + +```js +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: process.env['LANGSMITH_API_KEY'], // This is the default and can be omitted + tenantID: process.env['LANGSMITH_TENANT_ID'], // This is the default and can be omitted +}); + +const page = await client.runs.queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }); +const run = page.items[0]; + +console.log(run.id); +``` + +### Request & Response types + +This library includes TypeScript definitions for all request params and response fields. You may import and use them like so: + + +```ts +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: process.env['LANGSMITH_API_KEY'], // This is the default and can be omitted + tenantID: process.env['LANGSMITH_TENANT_ID'], // This is the default and can be omitted +}); + +const params: Langsmith.RunQueryV2Params = { + project_ids: ['00000000-0000-0000-0000-000000000000'], +}; +const [run]: [Langsmith.Run] = await client.runs.queryV2(params); +``` + +Documentation for each method, request param, and response field are available in docstrings and will appear on hover in most modern editors. + +## Handling errors + +When the library is unable to connect to the API, +or if the API returns a non-success status code (i.e., 4xx or 5xx response), +a subclass of `APIError` will be thrown: + + +```ts +const page = await client.runs + .queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }) + .catch(async (err) => { + if (err instanceof Langsmith.APIError) { + console.log(err.status); // 400 + console.log(err.name); // BadRequestError + console.log(err.headers); // {server: 'nginx', ...} + } else { + throw err; + } + }); +``` + +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors will be automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors will all be retried by default. + +You can use the `maxRetries` option to configure or disable this: + + +```js +// Configure the default for all requests: +const client = new Langsmith({ + maxRetries: 0, // default is 2 +}); + +// Or, configure per-request: +await client.runs.queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }, { + maxRetries: 5, +}); +``` + +### Timeouts + +Requests time out after 1.5 minutes by default. You can configure this with a `timeout` option: + + +```ts +// Configure the default for all requests: +const client = new Langsmith({ + timeout: 20 * 1000, // 20 seconds (default is 1.5 minutes) +}); + +// Override per-request: +await client.runs.queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }, { + timeout: 5 * 1000, +}); +``` + +On timeout, an `APIConnectionTimeoutError` is thrown. + +Note that requests which time out will be [retried twice by default](#retries). + +## Auto-pagination + +List methods in the Langsmith API are paginated. +You can use the `for await … of` syntax to iterate through items across all pages: + +```ts +async function fetchAllRuns(params) { + const allRuns = []; + // Automatically fetches more pages as needed. + for await (const run of client.runs.queryV2({ + project_ids: ['00000000-0000-0000-0000-000000000000'], + })) { + allRuns.push(run); + } + return allRuns; +} +``` + +Alternatively, you can request a single page at a time: + +```ts +let page = await client.runs.queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }); +for (const run of page.items) { + console.log(run); +} + +// Convenience methods are provided for manually paginating: +while (page.hasNextPage()) { + page = await page.getNextPage(); + // ... +} +``` + +## Advanced Usage + +### Accessing raw Response data (e.g., headers) + +The "raw" `Response` returned by `fetch()` can be accessed through the `.asResponse()` method on the `APIPromise` type that all methods return. +This method returns as soon as the headers for a successful response are received and does not consume the response body, so you are free to write custom parsing or streaming logic. + +You can also use the `.withResponse()` method to get the raw `Response` along with the parsed data. +Unlike `.asResponse()` this method consumes the body, returning once it is parsed. + + +```ts +const client = new Langsmith(); + +const response = await client.runs + .queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }) + .asResponse(); +console.log(response.headers.get('X-My-Header')); +console.log(response.statusText); // access the underlying Response object + +const { data: page, response: raw } = await client.runs + .queryV2({ project_ids: ['00000000-0000-0000-0000-000000000000'] }) + .withResponse(); +console.log(raw.headers.get('X-My-Header')); +for await (const run of page) { + console.log(run.id); +} +``` + +### Logging + +> [!IMPORTANT] +> All log messages are intended for debugging only. The format and content of log messages +> may change between releases. + +#### Log levels + +The log level can be configured in two ways: + +1. Via the `LANGCHAIN_LOG` environment variable +2. Using the `logLevel` client option (overrides the environment variable if set) + +```ts +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + logLevel: 'debug', // Show all log messages +}); +``` + +Available log levels, from most to least verbose: + +- `'debug'` - Show debug messages, info, warnings, and errors +- `'info'` - Show info messages, warnings, and errors +- `'warn'` - Show warnings and errors (default) +- `'error'` - Show only errors +- `'off'` - Disable all logging + +At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies. +Some authentication-related headers are redacted, but sensitive data in request and response bodies +may still be visible. + +#### Custom logger + +By default, this library logs to `globalThis.console`. You can also provide a custom logger. +Most logging libraries are supported, including [pino](https://www.npmjs.com/package/pino), [winston](https://www.npmjs.com/package/winston), [bunyan](https://www.npmjs.com/package/bunyan), [consola](https://www.npmjs.com/package/consola), [signale](https://www.npmjs.com/package/signale), and [@std/log](https://jsr.io/@std/log). If your logger doesn't work, please open an issue. + +When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages +below the configured level will not be sent to your logger. + +```ts +import Langsmith from 'langsmith'; +import pino from 'pino'; + +const logger = pino(); + +const client = new Langsmith({ + logger: logger.child({ name: 'Langsmith' }), + logLevel: 'debug', // Send all messages to pino, allowing it to filter +}); +``` + +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. If you need to access undocumented +endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints + +To make requests to undocumented endpoints, you can use `client.get`, `client.post`, and other HTTP verbs. +Options on the client, such as retries, will be respected when making these requests. + +```ts +await client.post('/some/path', { + body: { some_prop: 'foo' }, + query: { some_query_arg: 'bar' }, +}); +``` + +#### Undocumented request params + +To make requests using undocumented parameters, you may use `// @ts-expect-error` on the undocumented +parameter. This library doesn't validate at runtime that the request matches the type, so any extra values you +send will be sent as-is. + +```ts +client.runs.queryV2({ + // ... + // @ts-expect-error baz is not yet public + baz: 'undocumented option', +}); +``` + +For requests with the `GET` verb, any extra params will be in the query, all other requests will send the +extra param in the body. + +If you want to explicitly send an extra argument, you can do so with the `query`, `body`, and `headers` request +options. + +#### Undocumented response properties + +To access undocumented response properties, you may access the response object with `// @ts-expect-error` on +the response object, or cast the response object to the requisite type. Like the request params, we do not +validate or strip extra properties from the response from the API. + +### Customizing the fetch client + +By default, this library expects a global `fetch` function is defined. + +If you want to use a different `fetch` function, you can either polyfill the global: + +```ts +import fetch from 'my-fetch'; + +globalThis.fetch = fetch; +``` + +Or pass it to the client: + +```ts +import Langsmith from 'langsmith'; +import fetch from 'my-fetch'; + +const client = new Langsmith({ fetch }); +``` + +### Fetch options + +If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.) + +```ts +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + fetchOptions: { + // `RequestInit` options + }, +}); +``` + +#### Configuring proxies + +To modify proxy behavior, you can provide custom `fetchOptions` that add runtime-specific proxy +options to requests: + + **Node** [[docs](https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md#example---proxyagent-with-fetch)] + +```ts +import Langsmith from 'langsmith'; +import * as undici from 'undici'; + +const proxyAgent = new undici.ProxyAgent('http://localhost:8888'); +const client = new Langsmith({ + fetchOptions: { + dispatcher: proxyAgent, + }, +}); +``` + + **Bun** [[docs](https://bun.sh/guides/http/proxy)] + +```ts +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + fetchOptions: { + proxy: 'http://localhost:8888', + }, +}); +``` + + **Deno** [[docs](https://docs.deno.com/api/deno/~/Deno.createHttpClient)] + +```ts +import Langsmith from 'npm:langsmith'; + +const httpClient = Deno.createHttpClient({ proxy: { url: 'http://localhost:8888' } }); +const client = new Langsmith({ + fetchOptions: { + client: httpClient, + }, +}); +``` + +## Frequently Asked Questions + +## Semantic versioning + +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: + +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. + +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. + +We are keen for your feedback; please open an [issue](https://www.github.com/langchain-ai/langsmith-javascript/issues) with questions, bugs, or suggestions. + +## Requirements + +TypeScript >= 4.9 is supported. + +The following runtimes are supported: + +- Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more) +- Node.js 20 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. +- Deno v1.28.0 or higher. +- Bun 1.0 or later. +- Cloudflare Workers. +- Vercel Edge Runtime. +- Jest 28 or greater with the `"node"` environment (`"jsdom"` is not supported at this time). +- Nitro v2.6 or greater. + +Note that React Native is not supported at this time. + +If you are interested in other runtime environments, please open or upvote an issue on GitHub. + +## Contributing + +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..628142e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security Policy + +## Reporting Security Issues + +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. + +To report a security issue, please contact the Stainless team at security@stainless.com. + +## Responsible Disclosure + +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. + +## Reporting Non-SDK Related Security Issues + +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by LangChain, please follow the respective company's security reporting guidelines. + +--- + +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 0000000..2b87fd4 --- /dev/null +++ b/api.md @@ -0,0 +1,342 @@ +# Shared + +Types: + +- AgentAddress + +# ProductFeedback + +Types: + +- ProductFeedbackCreateResponse +- ProductFeedbackRetrieveResponse + +Methods: + +- client.productFeedback.create({ ...params }) -> ProductFeedbackCreateResponse +- client.productFeedback.retrieve(id) -> ProductFeedbackRetrieveResponse + +# Fleet + +## Threads + +Types: + +- ThreadActivateSandboxResponse + +Methods: + +- client.fleet.threads.activateSandbox(threadID) -> ThreadActivateSandboxResponse + +# Datasets + +Types: + +- DataType +- Dataset +- DatasetTransformation +- DatasetVersion +- FeedbackCreateCoreSchema +- Missing +- SortByDatasetColumn + +## Examples + +Methods: + +- client.datasets.examples.delete(exampleID, { ...params }) -> void + +## ExperimentRuns + +Types: + +- ExperimentRunQueryResponse + +Methods: + +- client.datasets.experimentRuns.query(datasetID, { ...params }) -> ExperimentRunQueryResponsesItemsCursorPostPagination + +# Runs + +Types: + +- ResponseBodyForRunsGenerateQuery +- Run +- RunIngest +- RunSchema +- RunSelectField +- RunStatsQueryParams +- RunType +- RunTypeEnum +- RunsFilterDataSourceTypeEnum +- RunGetURLResponse + +Methods: + +- client.runs.getURL(runID, { ...params }) -> RunGetURLResponse +- client.runs.queryV2({ ...params }) -> RunsItemsCursorPostPagination +- client.runs.retrieveV2(runID, { ...params }) -> Run + +## Share + +Types: + +- ShareCreateResponse + +Methods: + +- client.runs.share.create(runID, { ...params }) -> ShareCreateResponse +- client.runs.share.delete(traceID, { ...params }) -> void + +# Threads + +Types: + +- Thread +- ThreadStats +- ThreadTrace +- ThreadAggregateStatsResponse + +Methods: + +- client.threads.aggregateStats({ ...params }) -> ThreadAggregateStatsResponse +- client.threads.listTraces(threadID, { ...params }) -> ThreadTracesItemsCursorGetPagination +- client.threads.query({ ...params }) -> ThreadsItemsCursorPostPagination +- client.threads.stats(threadID, { ...params }) -> ThreadStats + +## Share + +Types: + +- ShareCreateResponse +- ShareRetrieveResponse + +Methods: + +- client.threads.share.create(threadID, { ...params }) -> ShareCreateResponse +- client.threads.share.retrieve(threadID, { ...params }) -> ShareRetrieveResponse +- client.threads.share.delete(threadID, { ...params }) -> void + +# Traces + +Types: + +- Trace +- TraceAggregates +- TraceListRunsResponse + +Methods: + +- client.traces.listRuns(traceID, { ...params }) -> TraceListRunsResponse +- client.traces.query({ ...params }) -> TracesItemsCursorPostPagination + +# OnlineEvaluators + +Types: + +- BulkDeleteEvaluatorFailedItem +- BulkDeleteEvaluatorsResponse +- CreateOnlineCodeEvaluatorRequest +- CreateOnlineEvaluatorRequest +- CreateOnlineEvaluatorResponse +- CreateOnlineLlmEvaluatorRequest +- GetOnlineEvaluatorSpendResponse +- OnlineCodeEvaluator +- OnlineEvaluator +- OnlineEvaluatorRunRule +- OnlineEvaluatorSpendDay +- OnlineEvaluatorSpendGroup +- OnlineEvaluatorType +- OnlineLlmEvaluator +- OnlineSpendLimit +- UpdateOnlineCodeEvaluatorRequest +- UpdateOnlineEvaluatorRequest +- UpdateOnlineEvaluatorResponse +- UpdateOnlineLlmEvaluatorRequest + +Methods: + +- client.onlineEvaluators.create({ ...params }) -> CreateOnlineEvaluatorResponse +- client.onlineEvaluators.retrieve(evaluatorID) -> OnlineEvaluator +- client.onlineEvaluators.update(evaluatorID, { ...params }) -> UpdateOnlineEvaluatorResponse +- client.onlineEvaluators.list({ ...params }) -> OnlineEvaluatorsOffsetPaginationOnlineEvaluators +- client.onlineEvaluators.delete(evaluatorID, { ...params }) -> void +- client.onlineEvaluators.bulkDelete({ ...params }) -> BulkDeleteEvaluatorsResponse +- client.onlineEvaluators.spend({ ...params }) -> GetOnlineEvaluatorSpendResponse + +# Public + +## Runs + +Types: + +- RunQueryResponse + +Methods: + +- client.public.runs.retrieve(runID, { ...params }) -> Run +- client.public.runs.query(shareToken, { ...params }) -> RunQueryResponse + +# AnnotationQueues + +Types: + +- AnnotationQueueRubricItemSchema +- AnnotationQueueSchema +- AnnotationQueueSizeSchema +- RunSchemaWithAnnotationQueueInfo +- AnnotationQueueRetrieveResponse +- AnnotationQueueUpdateResponse +- AnnotationQueueDeleteResponse +- AnnotationQueueCreateRunStatusResponse +- AnnotationQueueExportResponse +- AnnotationQueuePopulateResponse +- AnnotationQueueRetrieveAnnotationQueuesResponse +- AnnotationQueueRetrieveQueuesResponse + +Methods: + +- client.annotationQueues.retrieve(queueID) -> AnnotationQueueRetrieveResponse +- client.annotationQueues.update(queueID, { ...params }) -> unknown +- client.annotationQueues.delete(queueID) -> unknown +- client.annotationQueues.annotationQueues({ ...params }) -> AnnotationQueueSchema +- client.annotationQueues.createRunStatus(annotationQueueRunID, { ...params }) -> unknown +- client.annotationQueues.export(queueID, { ...params }) -> unknown +- client.annotationQueues.populate({ ...params }) -> unknown +- client.annotationQueues.retrieveAnnotationQueues({ ...params }) -> AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray +- client.annotationQueues.retrieveQueues(runID) -> AnnotationQueueRetrieveQueuesResponse +- client.annotationQueues.retrieveRun(index, { ...params }) -> RunSchemaWithAnnotationQueueInfo +- client.annotationQueues.retrieveSize(queueID, { ...params }) -> AnnotationQueueSizeSchema +- client.annotationQueues.retrieveTotalArchived(queueID, { ...params }) -> AnnotationQueueSizeSchema +- client.annotationQueues.retrieveTotalSize(queueID) -> AnnotationQueueSizeSchema + +## Runs + +Types: + +- RunCreateResponse +- RunUpdateResponse +- RunListResponse +- RunCreateByKeyResponse +- RunDeleteAllResponse +- RunDeleteQueueResponse + +Methods: + +- client.annotationQueues.runs.create(queueID, [ ...body ]) -> RunCreateResponse +- client.annotationQueues.runs.update(queueRunID, { ...params }) -> unknown +- client.annotationQueues.runs.list(queueID, { ...params }) -> RunListResponse +- client.annotationQueues.runs.createByKey(queueID, [ ...body ]) -> RunCreateByKeyResponse +- client.annotationQueues.runs.deleteAll(queueID, { ...params }) -> unknown +- client.annotationQueues.runs.deleteQueue(queueRunID, { ...params }) -> unknown + +## Items + +Types: + +- ItemCreateResponse +- ItemUpdateResponse +- ItemListResponse +- ItemCreateStatusResponse +- ItemDeleteAllResponse +- ItemRetrieveCountResponse +- ItemRetrievePlacementResponse + +Methods: + +- client.annotationQueues.items.create(queueID, { ...params }) -> ItemCreateResponse +- client.annotationQueues.items.update(itemID, { ...params }) -> ItemUpdateResponse +- client.annotationQueues.items.list(queueID, { ...params }) -> ItemListResponsesItemsCursorGetPagination +- client.annotationQueues.items.createStatus(queueItemID, { ...params }) -> ItemCreateStatusResponse +- client.annotationQueues.items.deleteAll(queueID, { ...params }) -> ItemDeleteAllResponse +- client.annotationQueues.items.retrieveCount(queueID, { ...params }) -> ItemRetrieveCountResponse +- client.annotationQueues.items.retrievePlacement(itemID, { ...params }) -> ItemRetrievePlacementResponse + +# Info + +Types: + +- InfoListResponse + +Methods: + +- client.info.list() -> InfoListResponse + +# Issues + +Types: + +- Issue + +Methods: + +- client.issues.retrieve(id, { ...params }) -> Issue +- client.issues.list({ ...params }) -> IssuesOffsetPaginationIssues + +# Sandboxes + +Types: + +- DownloadURLResponse +- SandboxListResponse +- SandboxResponse +- SandboxStatusResponse +- ServiceURLResponse +- SnapshotListResponse +- SnapshotResponse +- SandboxListUsageCostsResponse + +Methods: + +- client.sandboxes.listUsageCosts({ ...params }) -> SandboxListUsageCostsResponsesItemsCursorGetPagination + +## Boxes + +Types: + +- BoxListServiceURLsResponse + +Methods: + +- client.sandboxes.boxes.create({ ...params }) -> SandboxResponse +- client.sandboxes.boxes.retrieve(name) -> SandboxResponse +- client.sandboxes.boxes.update(name, { ...params }) -> SandboxResponse +- client.sandboxes.boxes.list({ ...params }) -> SandboxResponsesItemsCursorGetPagination +- client.sandboxes.boxes.delete(name) -> void +- client.sandboxes.boxes.createSnapshot(name, { ...params }) -> SnapshotResponse +- client.sandboxes.boxes.deleteServiceURL(name, { ...params }) -> void +- client.sandboxes.boxes.generateDownloadURL(name, { ...params }) -> DownloadURLResponse +- client.sandboxes.boxes.generateServiceURL(name, { ...params }) -> ServiceURLResponse +- client.sandboxes.boxes.getStatus(name) -> SandboxStatusResponse +- client.sandboxes.boxes.listServiceURLs(name, { ...params }) -> BoxListServiceURLsResponsesItemsCursorGetPagination +- client.sandboxes.boxes.start(name) -> SandboxResponse +- client.sandboxes.boxes.stop(name) -> void + +## Registries + +Types: + +- RegistryListResponse +- RegistryResponse + +Methods: + +- client.sandboxes.registries.create({ ...params }) -> RegistryResponse +- client.sandboxes.registries.retrieve(name) -> RegistryResponse +- client.sandboxes.registries.update(name, { ...params }) -> RegistryResponse +- client.sandboxes.registries.list({ ...params }) -> RegistryListResponse +- client.sandboxes.registries.delete(name) -> void + +## Snapshots + +Types: + +- SnapshotRetrieveByNameResponse + +Methods: + +- client.sandboxes.snapshots.create({ ...params }) -> SnapshotResponse +- client.sandboxes.snapshots.retrieve(snapshotID) -> SnapshotResponse +- client.sandboxes.snapshots.list({ ...params }) -> SnapshotResponsesItemsCursorGetPagination +- client.sandboxes.snapshots.delete(snapshotID) -> void +- client.sandboxes.snapshots.retrieveByName(name) -> SnapshotRetrieveByNameResponse diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 0000000..c43c74f --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${RELEASE_PLEASE_TOKEN}" ]; then + errors+=("The RELEASE_PLEASE_TOKEN secret has not been set. Create a fine-grained GitHub PAT and add it as a repository secret.") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" + diff --git a/bin/publish-npm b/bin/publish-npm new file mode 100644 index 0000000..a609989 --- /dev/null +++ b/bin/publish-npm @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -eux + +npm config set '//registry.npmjs.org/:_authToken' "$NPM_TOKEN" + +pnpm build +cd dist + +# Get package name and version from package.json +PACKAGE_NAME="$(jq -r -e '.name' ./package.json)" +VERSION="$(jq -r -e '.version' ./package.json)" + +# Get latest version from npm +# +# If the package doesn't exist, npm will return: +# { +# "error": { +# "code": "E404", +# "summary": "Unpublished on 2025-06-05T09:54:53.528Z", +# "detail": "'the_package' is not in this registry..." +# } +# } +NPM_INFO="$(npm view "$PACKAGE_NAME" version --json 2>/dev/null || true)" + +# Check if we got an E404 error +if echo "$NPM_INFO" | jq -e '.error.code == "E404"' > /dev/null 2>&1; then + # Package doesn't exist yet, no last version + LAST_VERSION="" +elif echo "$NPM_INFO" | jq -e '.error' > /dev/null 2>&1; then + # Report other errors + echo "ERROR: npm returned unexpected data:" + echo "$NPM_INFO" + exit 1 +else + # Success - get the version + LAST_VERSION=$(echo "$NPM_INFO" | jq -r '.') # strip quotes +fi + +# Check if current version is pre-release (e.g. alpha / beta / rc) +CURRENT_IS_PRERELEASE=false +if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then + CURRENT_IS_PRERELEASE=true + CURRENT_TAG="${BASH_REMATCH[1]}" +fi + +# Check if last version is a stable release +LAST_IS_STABLE_RELEASE=true +if [[ -z "$LAST_VERSION" || "$LAST_VERSION" =~ -([a-zA-Z]+) ]]; then + LAST_IS_STABLE_RELEASE=false +fi + +# Use a corresponding alpha/beta tag if there already is a stable release and we're publishing a prerelease. +if $CURRENT_IS_PRERELEASE && $LAST_IS_STABLE_RELEASE; then + TAG="$CURRENT_TAG" +else + TAG="latest" +fi + +# Publish with the appropriate tag +pnpm publish --no-git-checks --tag "$TAG" diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..fbc005a --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,49 @@ +// @ts-check +import tseslint from 'typescript-eslint'; +import unusedImports from 'eslint-plugin-unused-imports'; + +export default tseslint.config( + { + ignores: [ + 'dist/', + 'internal/environment_tests/**', + // vendored third-party code under the hand-written SDK + 'src/lib/utils/chalk/**', + 'src/lib/utils/console-table-printer/**', + 'src/lib/utils/simple-wcswidth/**', + ], + }, + { + languageOptions: { + parser: tseslint.parser, + parserOptions: { sourceType: 'module' }, + }, + files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.cjs'], + ignores: ['dist/'], + plugins: { + '@typescript-eslint': tseslint.plugin, + 'unused-imports': unusedImports, + }, + rules: { + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + regex: '^langsmith(/.*)?', + message: 'Use a relative import, not a package import.', + }, + ], + }, + ], + }, + }, + { + files: ['tests/**', 'examples/**'], + rules: { + 'no-restricted-imports': 'off', + }, + }, +); diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 0000000..0651c89 --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. diff --git a/internal/environment_tests/docker-compose.yml b/internal/environment_tests/docker-compose.yml new file mode 100644 index 0000000..d1740fe --- /dev/null +++ b/internal/environment_tests/docker-compose.yml @@ -0,0 +1,79 @@ +version: "3" + +services: + test-exports-esm: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-esm + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-cjs: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-cjs + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-cf: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-cf + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-vite: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-vite + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-webpack: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-webpack + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-esbuild: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-esbuild + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-exports-metro: + image: node:22 + working_dir: /app/internal/environment_tests/test-exports-metro + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + test-deepagent-compat: + image: node:22 + working_dir: /app/internal/environment_tests/test-deepagent-compat + volumes: + - ../..:/app + command: bash -c "corepack enable && pnpm install --frozen-lockfile && pnpm test" + + success: + image: alpine:3.14 + depends_on: + test-exports-esm: + condition: service_completed_successfully + test-exports-cjs: + condition: service_completed_successfully + test-exports-cf: + condition: service_completed_successfully + test-exports-vite: + condition: service_completed_successfully + test-exports-webpack: + condition: service_completed_successfully + test-exports-esbuild: + condition: service_completed_successfully + test-exports-metro: + condition: service_completed_successfully + test-deepagent-compat: + condition: service_completed_successfully + command: echo "All export tests passed!" diff --git a/internal/environment_tests/test-deepagent-compat/package.json b/internal/environment_tests/test-deepagent-compat/package.json new file mode 100644 index 0000000..1d7d76c --- /dev/null +++ b/internal/environment_tests/test-deepagent-compat/package.json @@ -0,0 +1,23 @@ +{ + "name": "test-deepagent-compat", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Smoke test: deepagent installs and imports correctly with the local langsmith SDK", + "scripts": { + "test": "npx tsx src/index.ts" + }, + "dependencies": { + "deepagents": "*", + "langsmith": "file:../../../dist" + }, + "pnpm": { + "overrides": { + "langsmith": "file:../../../dist" + } + }, + "devDependencies": { + "tsx": "^4.7.0", + "@types/node": "^20.0.0" + } +} diff --git a/internal/environment_tests/test-deepagent-compat/pnpm-lock.yaml b/internal/environment_tests/test-deepagent-compat/pnpm-lock.yaml new file mode 100644 index 0000000..874d149 --- /dev/null +++ b/internal/environment_tests/test-deepagent-compat/pnpm-lock.yaml @@ -0,0 +1,911 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + langsmith: file:../../../dist + +importers: + + .: + dependencies: + deepagents: + specifier: '*' + version: 1.10.5(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + langsmith: + specifier: file:../../../dist + version: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + tsx: + specifier: ^4.7.0 + version: 4.22.4 + +packages: + + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@langchain/core@1.2.1': + resolution: {integrity: sha512-NNG/cC5FGuHDOAP56h0ddp8Rfk8p+othWzEK5RV9JIG6RvnF5vGa5r0AEGtKfQieed7s1kC42GuIzVOBvMBL/g==} + engines: {node: '>=20'} + + '@langchain/langgraph-checkpoint@1.1.3': + resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.48 + + '@langchain/langgraph-sdk@1.9.25': + resolution: {integrity: sha512-mRKW8zyQUaHox+HirRFMRrPqOvNbQI3xeXDt6kkk4PbBg77V92bsO1WzUVNrmJ81zCkvxyOrWSK8D6ioCj0a8A==} + peerDependencies: + '@langchain/core': ^1.1.48 + react: ^18 || ^19 + react-dom: ^18 || ^19 + svelte: ^4.0.0 || ^5.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + svelte: + optional: true + vue: + optional: true + + '@langchain/langgraph@1.4.7': + resolution: {integrity: sha512-2tcyf3QGC7v89kqSxMCtRvzg/3L/4yHtOaWC49A8KieCciWJs7LGaxHoPB6QRxXyUgyR+Zg9Q1ss/XJIE+JuSQ==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.48 + zod: ^3.25.32 || ^4.2.0 + + '@langchain/protocol@0.0.18': + resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + deepagents@1.10.5: + resolution: {integrity: sha512-UFXoH3obz+/3ACuq515UHxXGiDQXHlXvK99ywZ2FSw/HrlrKwI0SKgd0damIj7Tpz7UYrCk4YRzufeDzaOEaQg==} + version: 1.10.5 + peerDependencies: + langsmith: ^0.7.1 + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-network-error@1.3.2: + resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} + engines: {node: '>=16'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + js-tiktoken@1.0.21: + resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} + + langchain@1.5.2: + resolution: {integrity: sha512-5vCWYvzxuY7gJ8UCgSZ17SM45gou5PtRguFgeQIyCnHzGZQUFLHKi/eQArL3Ad98fJ/UiOEAaTXiI3jfIdoABg==} + engines: {node: '>=20'} + peerDependencies: + '@langchain/core': ^1.2.1 + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + + openai@6.45.0: + resolution: {integrity: sha512-5DQVNErssk0afNpTTHUm/qZPU4iKR9OYdNid8Ib4puq4gHNNvGWZht2zY4h9a8JMF949Ik6m8gQutllVPbjdnw==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-queue@9.3.0: + resolution: {integrity: sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==} + engines: {node: '>=20'} + + p-retry@7.1.1: + resolution: {integrity: sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==} + engines: {node: '>=20'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-timeout@7.0.1: + resolution: {integrity: sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==} + engines: {node: '>=20'} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@cfworker/json-schema@4.1.1': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)': + dependencies: + '@cfworker/json-schema': 4.1.1 + '@standard-schema/spec': 1.1.0 + js-tiktoken: 1.0.21 + langsmith: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + mustache: 4.2.0 + p-queue: 6.6.2 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))': + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + + '@langchain/langgraph-sdk@1.9.25(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))': + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/protocol': 0.0.18 + '@types/json-schema': 7.0.15 + p-queue: 9.3.0 + p-retry: 7.1.1 + + '@langchain/langgraph@1.4.7(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(zod@4.4.3)': + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) + '@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) + '@langchain/protocol': 0.0.18 + '@standard-schema/spec': 1.1.0 + zod: 4.4.3 + transitivePeerDependencies: + - react + - react-dom + - svelte + - vue + + '@langchain/protocol@0.0.18': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.41.1 + optional: true + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + optional: true + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + optional: true + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.41.1 + optional: true + + '@opentelemetry/semantic-conventions@1.41.1': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + base64-js@1.5.1: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + deepagents@1.10.5(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/langgraph': 1.4.7(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(zod@4.4.3) + '@langchain/langgraph-sdk': 1.9.25(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) + fast-glob: 3.3.3 + langchain: 1.5.2(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + langsmith: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + micromatch: 4.0.8 + yaml: 2.9.0 + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - svelte + - vue + - ws + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + eventemitter3@4.0.7: {} + + eventemitter3@5.0.4: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fsevents@2.3.3: + optional: true + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-network-error@1.3.2: {} + + is-number@7.0.0: {} + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + langchain@1.5.2(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + dependencies: + '@langchain/core': 1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + '@langchain/langgraph': 1.4.7(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0))(zod@4.4.3) + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.1(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0)) + langsmith: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + zod: 4.4.3 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - react + - react-dom + - svelte + - vue + - ws + + langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1))(openai@6.45.0(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + openai: 6.45.0(ws@8.21.0)(zod@4.4.3) + ws: 8.21.0 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mustache@4.2.0: {} + + openai@6.45.0(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + ws: 8.21.0 + zod: 4.4.3 + optional: true + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-queue@9.3.0: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.1 + + p-retry@7.1.1: + dependencies: + is-network-error: 1.3.2 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-timeout@7.0.1: {} + + picomatch@2.3.2: {} + + queue-microtask@1.2.3: {} + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + undici-types@6.21.0: {} + + ws@8.21.0: + optional: true + + yaml@2.9.0: {} + + zod@4.4.3: {} diff --git a/internal/environment_tests/test-deepagent-compat/pnpm-workspace.yaml b/internal/environment_tests/test-deepagent-compat/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-deepagent-compat/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-deepagent-compat/src/index.ts b/internal/environment_tests/test-deepagent-compat/src/index.ts new file mode 100644 index 0000000..540d033 --- /dev/null +++ b/internal/environment_tests/test-deepagent-compat/src/index.ts @@ -0,0 +1,8 @@ +import { createDeepAgent } from "deepagents"; + +console.log("Testing deepagent compatibility with local langsmith..."); + +const agent = createDeepAgent(); +console.log("✓ deepagents imported and compiled with local langsmith"); + +console.log("\n✅ deepagent compatibility smoke test passed!"); diff --git a/internal/environment_tests/test-exports-cf/package.json b/internal/environment_tests/test-exports-cf/package.json new file mode 100644 index 0000000..3aa2426 --- /dev/null +++ b/internal/environment_tests/test-exports-cf/package.json @@ -0,0 +1,23 @@ +{ + "name": "test-exports-cf", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tests that langsmith exports work correctly in Cloudflare Workers (edge runtime)", + "scripts": { + "test": "npx wrangler deploy --dry-run --outdir=dist" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "wrangler": "^4.110.0" + }, + "pnpm": { + "overrides": { + "sharp": "0.35.4", + "ws": "8.21.0", + "undici": "7.29.1" + } + } +} diff --git a/internal/environment_tests/test-exports-cf/pnpm-lock.yaml b/internal/environment_tests/test-exports-cf/pnpm-lock.yaml new file mode 100644 index 0000000..5c32384 --- /dev/null +++ b/internal/environment_tests/test-exports-cf/pnpm-lock.yaml @@ -0,0 +1,1464 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + sharp: 0.35.4 + ws: 8.21.0 + undici: 7.29.1 + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.78)(@smithy/signature-v4@5.6.12)(ws@8.21.0)(zod@4.4.3))(ws@8.21.0) + devDependencies: + wrangler: + specifier: ^4.110.0 + version: 4.120.0 + +packages: + + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260801.1': + resolution: {integrity: sha512-wuJWbXpKvncJi1P0GKS+iYpN5tHdb7JPJJ/+6ZQe8zzovHHVMkLJPNBsgWpqeUhpM3g9qTwEKd2rglNKejuh5A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260801.1': + resolution: {integrity: sha512-kwoZiTpnhNrF3+APx84Q/oAqvJ3sU9yefGagwm/ASaH/2W19x0vghkW/r4qCoHCK0WW7EPugZ+aXjgPMRtlq1Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260801.1': + resolution: {integrity: sha512-r0vAxCZH+Jih9Unm1yoyiByPNWNgawcKciOHDm5Q37ZVGOkKLsT9AtLe3yLSaul76WrKqtf+JP2n0WW32VBLJg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260801.1': + resolution: {integrity: sha512-zWgpdZtSozvIgzQNmQiDSF8yEOQJUkRAWNsDXXzAAoy+fCn8YUoSibj3mpFSbZRvbUldeBEaW6SCdC2VEMkhNQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260801.1': + resolution: {integrity: sha512-2oQz+Ksu4ji6e/+ZoYX+tWQEcxAii2p7l+iR8kx48W1llMalaufAsmVxTlk+3/vrM7D3/2c0iK448e0UQTcIMg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: 8.21.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + miniflare@5.20260801.1-alpha: + resolution: {integrity: sha512-BHPVzIDA6mbx7LefxpvkXW7DHx9FKB9GorZatbnrrFTt3CVMU8zuUpbgyCuebwDKcTTOZos43ta8GQ0eMVEpxA==} + engines: {node: '>=22.0.0'} + + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: 8.21.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + workerd@1.20260801.1: + resolution: {integrity: sha512-/g9JGTyqnHtoIscpBHqKD8swE2V4StBs2i69PmLiOhH45OP95jCFICl4F1hKlgN57rqfni5LiCitttoX5OOkVA==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.120.0: + resolution: {integrity: sha512-cBmu/MeaB/fPacC0JpATs4duTOCagBxrZo+vBzuTX06tLzwSyAHE1drlHUZ8rP0VqVz1fy3ReGYTiHdKkoHltg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260801.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@aws-sdk/core@3.977.6': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-env@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-web-identity@3.972.73': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/nested-clients@3.997.41': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/token-providers@3.1103.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws/lambda-invoke-store@0.3.0': + optional: true + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260801.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260801.1 + + '@cloudflare/workerd-darwin-64@1.20260801.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260801.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260801.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260801.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260801.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/semantic-conventions@1.43.0': + optional: true + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@sindresorhus/is@7.2.0': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@speed-highlight/core@1.2.24': {} + + blake3-wasm@2.1.5: {} + + bowser@2.14.1: + optional: true + + cookie@1.1.1: {} + + detect-libc@2.1.2: {} + + error-stack-parser-es@1.0.5: {} + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + eventemitter3@4.0.7: {} + + fsevents@2.3.3: + optional: true + + kleur@4.1.5: {} + + langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.78)(@smithy/signature-v4@5.6.12)(ws@8.21.0)(zod@4.4.3))(ws@8.21.0): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.78)(@smithy/signature-v4@5.6.12)(ws@8.21.0)(zod@4.4.3) + ws: 8.21.0 + + miniflare@5.20260801.1-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.4 + undici: 7.29.1 + workerd: 1.20260801.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + openai@6.49.0(@aws-sdk/credential-provider-node@3.972.78)(@smithy/signature-v4@5.6.12)(ws@8.21.0)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.78 + '@smithy/signature-v4': 5.6.12 + ws: 8.21.0 + zod: 4.4.3 + optional: true + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + semver@7.8.5: {} + + sharp@0.35.4: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + + supports-color@10.2.2: {} + + tslib@2.8.1: + optional: true + + undici@7.29.1: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + workerd@1.20260801.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260801.1 + '@cloudflare/workerd-darwin-arm64': 1.20260801.1 + '@cloudflare/workerd-linux-64': 1.20260801.1 + '@cloudflare/workerd-linux-arm64': 1.20260801.1 + '@cloudflare/workerd-windows-64': 1.20260801.1 + + wrangler@4.120.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260801.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260801.1-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260801.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.4.3: + optional: true diff --git a/internal/environment_tests/test-exports-cf/pnpm-workspace.yaml b/internal/environment_tests/test-exports-cf/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-cf/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-cf/src/index.ts b/internal/environment_tests/test-exports-cf/src/index.ts new file mode 100644 index 0000000..e294ae1 --- /dev/null +++ b/internal/environment_tests/test-exports-cf/src/index.ts @@ -0,0 +1,44 @@ +/** + * Cloudflare Workers environment test for langsmith exports. + * Tests that public API works correctly in edge runtime environments. + * + * Note: Cache dump/load are not supported in edge environments. + */ + +import { Client, PromptCache } from "langsmith"; + +export default { + async fetch(): Promise { + const results: string[] = []; + + try { + // Test Client import and instantiation + const client = new Client({ apiKey: "test-key" }); + results.push("✓ Client imported and instantiated"); + + // Test Cache import and instantiation + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + results.push("✓ Cache imported and instantiated"); + cache.stop(); + + // Test Client with cache enabled + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + results.push("✓ Client with cache enabled"); + + results.push("\n✅ All Cloudflare export tests passed!"); + + return new Response(results.join("\n"), { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + } catch (err) { + return new Response(`❌ Test failed: ${err}`, { + status: 500, + headers: { "Content-Type": "text/plain" }, + }); + } + }, +}; diff --git a/internal/environment_tests/test-exports-cf/wrangler.toml b/internal/environment_tests/test-exports-cf/wrangler.toml new file mode 100644 index 0000000..03d1f73 --- /dev/null +++ b/internal/environment_tests/test-exports-cf/wrangler.toml @@ -0,0 +1,3 @@ +name = "test-exports-cf" +main = "src/index.ts" +compatibility_date = "2024-01-01" diff --git a/internal/environment_tests/test-exports-cjs/package.json b/internal/environment_tests/test-exports-cjs/package.json new file mode 100644 index 0000000..78c01ab --- /dev/null +++ b/internal/environment_tests/test-exports-cjs/package.json @@ -0,0 +1,12 @@ +{ + "name": "test-exports-cjs", + "version": "0.0.0", + "private": true, + "description": "Tests that langsmith exports work correctly in CommonJS environments", + "scripts": { + "test": "node src/index.js" + }, + "dependencies": { + "langsmith": "file:../../../dist" + } +} diff --git a/internal/environment_tests/test-exports-cjs/pnpm-lock.yaml b/internal/environment_tests/test-exports-cjs/pnpm-lock.yaml new file mode 100644 index 0000000..5919970 --- /dev/null +++ b/internal/environment_tests/test-exports-cjs/pnpm-lock.yaml @@ -0,0 +1,69 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist + +packages: + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + +snapshots: + + eventemitter3@4.0.7: {} + + langsmith@file:../../../dist: + dependencies: + p-queue: 6.6.2 + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 diff --git a/internal/environment_tests/test-exports-cjs/pnpm-workspace.yaml b/internal/environment_tests/test-exports-cjs/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-cjs/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-cjs/src/index.js b/internal/environment_tests/test-exports-cjs/src/index.js new file mode 100644 index 0000000..5b1e3cf --- /dev/null +++ b/internal/environment_tests/test-exports-cjs/src/index.js @@ -0,0 +1,46 @@ +/** + * CommonJS environment test for langsmith exports. + * Tests that public API works correctly in CJS environments. + */ + +const { Client, PromptCache } = require("langsmith"); +const { RunTree } = require("langsmith/run_trees"); +const { traceable } = require("langsmith/traceable"); + +async function testExports() { + console.log("Testing CJS exports..."); + + // Test Client import and instantiation + const client = new Client({ apiKey: "test-key" }); + console.log("✓ Client imported and instantiated"); + + // Test RunTree import + const runTree = new RunTree({ name: "test", run_type: "chain" }); + console.log("✓ RunTree imported and instantiated"); + + // Test traceable import + const traced = traceable((x) => x, { name: "test" }); + console.log("✓ traceable imported successfully"); + + // Test Cache import and instantiation + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + console.log("✓ Cache imported and instantiated"); + cache.stop(); + + // Test Client with cache + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + console.log("✓ Client with cache enabled"); + clientWithCache.cleanup(); + + console.log("\n✅ All CJS export tests passed!"); +} + +testExports() + .then(() => process.exit(0)) + .catch((err) => { + console.error("❌ Test failed:", err); + process.exit(1); + }); diff --git a/internal/environment_tests/test-exports-esbuild/package.json b/internal/environment_tests/test-exports-esbuild/package.json new file mode 100644 index 0000000..d54ff47 --- /dev/null +++ b/internal/environment_tests/test-exports-esbuild/package.json @@ -0,0 +1,16 @@ +{ + "name": "test-exports-esbuild", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tests that langsmith exports work correctly in esbuild browser builds", + "scripts": { + "test": "esbuild src/index.ts --bundle --platform=browser --outfile=dist/bundle.js" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "esbuild": "^0.28.1" + } +} diff --git a/internal/environment_tests/test-exports-esbuild/pnpm-lock.yaml b/internal/environment_tests/test-exports-esbuild/pnpm-lock.yaml new file mode 100644 index 0000000..8b3faa0 --- /dev/null +++ b/internal/environment_tests/test-exports-esbuild/pnpm-lock.yaml @@ -0,0 +1,341 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist + devDependencies: + esbuild: + specifier: ^0.28.1 + version: 0.28.1 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + eventemitter3@4.0.7: {} + + langsmith@file:../../../dist: + dependencies: + p-queue: 6.6.2 + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 diff --git a/internal/environment_tests/test-exports-esbuild/pnpm-workspace.yaml b/internal/environment_tests/test-exports-esbuild/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-esbuild/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-esbuild/src/index.ts b/internal/environment_tests/test-exports-esbuild/src/index.ts new file mode 100644 index 0000000..0e1b931 --- /dev/null +++ b/internal/environment_tests/test-exports-esbuild/src/index.ts @@ -0,0 +1,34 @@ +/** + * esbuild browser environment test for langsmith exports. + * Tests that public API works correctly when bundled with esbuild for browsers. + * + * This test verifies: + * 1. esbuild can build langsmith for browser targets (platform=browser) + * 2. The browser field in package.json correctly swaps Node-specific code + * 3. Client export is available (uses fetch, works in browsers) + * 4. Cache export is available (in-memory caching works in browsers) + * + * Note: Cache dump/load are not supported in browsers. + */ + +import { Client, PromptCache } from "langsmith"; + +// Export to prevent tree-shaking +export { Client, PromptCache }; + +// Simple test that instantiation works +export function testExports() { + const client = new Client({ apiKey: "test-key" }); + + // Test Cache - in-memory caching works in browsers + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + cache.stop(); + + // Test Client with cache enabled + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + + return { client, cache, clientWithCache }; +} diff --git a/internal/environment_tests/test-exports-esbuild/tsconfig.json b/internal/environment_tests/test-exports-esbuild/tsconfig.json new file mode 100644 index 0000000..875cb60 --- /dev/null +++ b/internal/environment_tests/test-exports-esbuild/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": {} +} diff --git a/internal/environment_tests/test-exports-esm/package.json b/internal/environment_tests/test-exports-esm/package.json new file mode 100644 index 0000000..506e5de --- /dev/null +++ b/internal/environment_tests/test-exports-esm/package.json @@ -0,0 +1,18 @@ +{ + "name": "test-exports-esm", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tests that langsmith exports work correctly in ESM environments", + "scripts": { + "test": "tsc --noEmit && tsx src/index.ts" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.7.0", + "typescript": "^6.0.3" + } +} diff --git a/internal/environment_tests/test-exports-esm/pnpm-lock.yaml b/internal/environment_tests/test-exports-esm/pnpm-lock.yaml new file mode 100644 index 0000000..3e9fec6 --- /dev/null +++ b/internal/environment_tests/test-exports-esm/pnpm-lock.yaml @@ -0,0 +1,385 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.43 + tsx: + specifier: ^4.7.0 + version: 4.23.0 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + +packages: + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + tsx@4.23.0: + resolution: {integrity: sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + eventemitter3@4.0.7: {} + + fsevents@2.3.3: + optional: true + + langsmith@file:../../../dist: + dependencies: + p-queue: 6.6.2 + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + tsx@4.23.0: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@6.0.3: {} + + undici-types@6.21.0: {} diff --git a/internal/environment_tests/test-exports-esm/pnpm-workspace.yaml b/internal/environment_tests/test-exports-esm/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-esm/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-esm/src/index.ts b/internal/environment_tests/test-exports-esm/src/index.ts new file mode 100644 index 0000000..bb93816 --- /dev/null +++ b/internal/environment_tests/test-exports-esm/src/index.ts @@ -0,0 +1,46 @@ +/** + * ESM environment test for langsmith exports. + * Tests that public API works correctly in ES module environments. + */ + +import { Client, PromptCache } from "langsmith"; +import { RunTree } from "langsmith/run_trees"; +import { traceable } from "langsmith/traceable"; + +async function testExports() { + console.log("Testing ESM exports..."); + + // Test Client import and instantiation + const client = new Client({ apiKey: "test-key" }); + console.log("✓ Client imported and instantiated"); + + // Test RunTree import + const runTree = new RunTree({ name: "test", run_type: "chain" }); + console.log("✓ RunTree imported and instantiated"); + + // Test traceable import + const traced = traceable((x: string) => x, { name: "test" }); + console.log("✓ traceable imported successfully"); + + // Test Cache import and instantiation + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + console.log("✓ Cache imported and instantiated"); + cache.stop(); + + // Test Client with cache + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + console.log("✓ Client with cache enabled"); + clientWithCache.cleanup(); + + console.log("\n✅ All ESM export tests passed!"); +} + +testExports() + .then(() => process.exit(0)) + .catch((err) => { + console.error("❌ Test failed:", err); + process.exit(1); + }); diff --git a/internal/environment_tests/test-exports-esm/src/typecheck.ts b/internal/environment_tests/test-exports-esm/src/typecheck.ts new file mode 100644 index 0000000..12da87b --- /dev/null +++ b/internal/environment_tests/test-exports-esm/src/typecheck.ts @@ -0,0 +1,3 @@ +import * as langsmith from "langsmith"; + +void langsmith; diff --git a/internal/environment_tests/test-exports-esm/tsconfig.json b/internal/environment_tests/test-exports-esm/tsconfig.json new file mode 100644 index 0000000..34bceb9 --- /dev/null +++ b/internal/environment_tests/test-exports-esm/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "exactOptionalPropertyTypes": false, + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*.ts"] +} diff --git a/internal/environment_tests/test-exports-metro/babel.config.js b/internal/environment_tests/test-exports-metro/babel.config.js new file mode 100644 index 0000000..3c25e5d --- /dev/null +++ b/internal/environment_tests/test-exports-metro/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: ["module:@react-native/babel-preset"], +}; diff --git a/internal/environment_tests/test-exports-metro/package.json b/internal/environment_tests/test-exports-metro/package.json new file mode 100644 index 0000000..a3c1634 --- /dev/null +++ b/internal/environment_tests/test-exports-metro/package.json @@ -0,0 +1,16 @@ +{ + "name": "test-exports-metro", + "version": "0.0.0", + "private": true, + "description": "Tests that langsmith exports work correctly in Metro bundler (React Native)", + "scripts": { + "test": "mkdir -p dist && metro build src/index.js --out dist/bundle.js" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "@react-native/babel-preset": "^0.87.1", + "metro": "^0.87.1" + } +} diff --git a/internal/environment_tests/test-exports-metro/pnpm-lock.yaml b/internal/environment_tests/test-exports-metro/pnpm-lock.yaml new file mode 100644 index 0000000..af40a41 --- /dev/null +++ b/internal/environment_tests/test-exports-metro/pnpm-lock.yaml @@ -0,0 +1,2678 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.1))(ws@8.21.3) + devDependencies: + '@react-native/babel-preset': + specifier: ^0.87.1 + version: 0.87.1(@babel/core@7.29.7) + metro: + specifier: ^0.87.1 + version: 0.87.1 + +packages: + + '@aws-sdk/core@3.978.0': + resolution: {integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.71': + resolution: {integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.73': + resolution: {integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.16': + resolution: {integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.78': + resolution: {integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.83': + resolution: {integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.71': + resolution: {integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.15': + resolution: {integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.77': + resolution: {integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.45': + resolution: {integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: {integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1129.0': + resolution: {integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.5': + resolution: {integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.40': + resolution: {integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-create-regexp-features-plugin@7.29.7': + resolution: {integrity: sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-define-polyfill-provider@0.6.8': + resolution: {integrity: sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-remap-async-to-generator@7.29.7': + resolution: {integrity: sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-wrap-function@7.29.7': + resolution: {integrity: sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-proposal-export-default-from@7.29.7': + resolution: {integrity: sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-dynamic-import@7.8.3': + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-export-default-from@7.29.7': + resolution: {integrity: sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-flow@7.29.7': + resolution: {integrity: sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-generator-functions@7.29.7': + resolution: {integrity: sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-async-to-generator@7.29.7': + resolution: {integrity: sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-block-scoping@7.29.7': + resolution: {integrity: sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-class-properties@7.29.7': + resolution: {integrity: sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-classes@7.29.7': + resolution: {integrity: sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-destructuring@7.29.7': + resolution: {integrity: sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-flow-strip-types@7.29.7': + resolution: {integrity: sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-for-of@7.29.7': + resolution: {integrity: sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7': + resolution: {integrity: sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7': + resolution: {integrity: sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-catch-binding@7.29.7': + resolution: {integrity: sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-optional-chaining@7.29.7': + resolution: {integrity: sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-methods@7.29.7': + resolution: {integrity: sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-private-property-in-object@7.29.7': + resolution: {integrity: sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-display-name@7.29.7': + resolution: {integrity: sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx@7.29.7': + resolution: {integrity: sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-regenerator@7.29.8': + resolution: {integrity: sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-runtime@7.29.7': + resolution: {integrity: sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-unicode-regex@7.29.7': + resolution: {integrity: sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@jest/schemas@29.6.3': + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jest/types@29.6.3': + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: {integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: {integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@react-native/babel-plugin-codegen@0.87.1': + resolution: {integrity: sha512-DfnyLAHG7jH4pqeID7ib6AeD6gG8T+KvM3c/w/yEa5ONkfRcHvTC8JMIRdhUqs4ruA+8xbYcNVQzewYRCPm1Xw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + '@react-native/babel-preset@0.87.1': + resolution: {integrity: sha512-EN1oo8IqsJgq++na/buq6hYsdZJY5A8+URohSA620eGz5a+322WOAcBuvcYZTeovYwR2NegvpPk5h+QVk8iUMw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + peerDependencies: + '@babel/core': '*' + + '@react-native/codegen@0.87.1': + resolution: {integrity: sha512-qbaqEdlUfj2vRgvWTpMoNgHnEqAhAYJLUrpGkb0WC9n0kdtqUvgigpz4bDktZwolM9BemXwgGFgyiAnbs3t0xw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + peerDependencies: + '@babel/core': '*' + + '@sinclair/typebox@0.27.12': + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + + '@smithy/core@3.33.3': + resolution: {integrity: sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.3': + resolution: {integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.18.0': + resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} + engines: {node: '>=18.0.0'} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/node@25.9.6': + resolution: {integrity: sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + babel-plugin-polyfill-corejs2@0.4.17: + resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-corejs3@0.13.0: + resolution: {integrity: sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-polyfill-regenerator@0.6.8: + resolution: {integrity: sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + + babel-plugin-syntax-hermes-parser@0.36.1: + resolution: {integrity: sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==} + + babel-plugin-transform-flow-enums@0.0.2: + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + + content-type@2.1.0: + resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-js-compat@3.50.0: + resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} + engines: {node: '>=6.4.0'} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.425: + resolution: {integrity: sha512-QvPtl41EUOnuT1HBvMKgxXRIaHNcagBPs50u7VULzhZXaGfqTbZyE16LQsctZ/RQHlGu+FOWeDTR4mY6YbeF1g==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + + flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + + flow-estree@0.331.0: + resolution: {integrity: sha512-FVLYkSL/ITb/QXBEQvNWPjPooRGswuUtGOwrH+puSlMDneNkxy640+FZsk/TfKL+b7Wbw/Fg9FTUv2bMQDpj2w==} + engines: {node: '>=18'} + + flow-parser@0.331.0: + resolution: {integrity: sha512-vEZcHIlnKeN8JSVtYFczj8sWJfHIJOkz+YG00O/oul/Bete+t1ZhTi+O6JRRLFYvGW+yJkZ0YgL16iStnbkUqQ==} + engines: {node: '>=0.4.0'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.36.1: + resolution: {integrity: sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==} + + hermes-parser@0.36.1: + resolution: {integrity: sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + metro-babel-transformer@0.87.1: + resolution: {integrity: sha512-orvRIGpb0yK1yMbk5YP3dhVKjmoxUkRB9fdltGJxNppEqus/1tFpNuf9KnGFR3Qjg0izoUrJ4z6WgoBvdkXPrA==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-cache-key@0.87.1: + resolution: {integrity: sha512-scqVVPMA2c+RVO12I3wyMDp5xCkRPLsyrcG4Qc3xfU5JR0EW/0sz2tjty+5UjMUtG09uiM1KYQ8HjWg7OQXHtw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-cache@0.87.1: + resolution: {integrity: sha512-juNPaj0Xi5tkLp8imXqvbSnEIijwKG36m7OiFFVJMqEkiURwAjkMFVek3/xpix1+LVQqO7kjVp3PvuvIM61mKQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-config@0.87.1: + resolution: {integrity: sha512-NmkDlc/qZAdo5gTkVTuO2AKE/khemRKRH5IA+SKfqUBJigI5GqZ5TOXhmEokhlkAqzjt8dyzKhzI6FHvXoXaTQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-core@0.87.1: + resolution: {integrity: sha512-xizzky/4+c/Mkznege8OW05j7bSdM39VPoEps61Se4+hf32UQujmbvLvIE9+UnP6kNEPVomk0Fw8NsBK9iRQmw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-file-map@0.87.1: + resolution: {integrity: sha512-9sPuNCojC3pS4vj+ZPY7FXmHGpdJeckXuKXQrDAfJvZc60AF8owRFTkI8XdwlwrmPIB2w++KraK9FMEXvC4wJw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-minify-terser@0.87.1: + resolution: {integrity: sha512-YK4k1oO1wV48hfE+Nbw2rJCnAgeabzrgT/xH5ha5n9gbK4p3HYFC898cERUiyTl9vRh58CNTiXRc88f7y6PfKg==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-resolver@0.87.1: + resolution: {integrity: sha512-FXH/brY69wT6sPxaSW8BhMmDGD/6irmBr/cL4rUi+VWwqR3pbvdcfUOfyjcHucgQ7pe5IW3xJY21eHKLVKNumQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-runtime@0.87.1: + resolution: {integrity: sha512-kjeuSvInsM6OzBjkCQsWxnkrKZFMKtnkCl38oXWRI9lDGs2L9hGs9Tak9sQYJ9h3gtDujneB7wZio+irgqJKDw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-source-map@0.87.1: + resolution: {integrity: sha512-Bqpm0PBGdy53pigIJb4HRF7QxD2pUOdgSmVh/4AWbP635kSCv+Y6ltVxUZgSnzS/QbSmzyCGijmRPlXMj2/qHw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-symbolicate@0.87.1: + resolution: {integrity: sha512-rcGvwabrg0lbXGRGRKl14PY3My3+dkno2jZYhdJEuH2DmjTtTkV5bYwgto4JtV/Lbe+sXE6GO1UGg654430uow==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + hasBin: true + + metro-transform-plugins@0.87.1: + resolution: {integrity: sha512-qMiJ/x+VfqumFJJSefKS6nj8uojzC29b2/aK8vlMuFABIqGjDucICEOXd2Yt6k/XMFAgAcaJvShUOT0HLyYaPw==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro-transform-worker@0.87.1: + resolution: {integrity: sha512-VOzs3OV405FuOLYdAhqA2wsn1F9lvueDVxMDLvCKaQ44U+yknIqphJ9eMjjsGwLDZiJq9sW3DMLhjD27JxQgqQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + metro@0.87.1: + resolution: {integrity: sha512-1oyLU9elM7hPAsKIqKooEekwxiuWr27+MZBhUROPhzcbXhMDxK64GPF/hCXwBZVp1F89ODavCrBmJtLx9WtBoQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + hasBin: true + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.1.0: + resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==} + engines: {node: '>=18'} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.55: + resolution: {integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==} + engines: {node: '>=18'} + + nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + + ob1@0.87.1: + resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==} + engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + + on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-refresh@0.14.2: + resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} + engines: {node: '>=0.10.0'} + + regenerate-unicode-properties@10.2.2: + resolution: {integrity: sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==} + engines: {node: '>=4'} + + regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + regexpu-core@6.4.0: + resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==} + engines: {node: '>=4'} + + regjsgen@0.8.0: + resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} + + regjsparser@0.13.2: + resolution: {integrity: sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==} + hasBin: true + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + + statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + engines: {node: '>=10'} + hasBin: true + + throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unicode-canonical-property-names-ecmascript@2.0.1: + resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} + engines: {node: '>=4'} + + unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + + unicode-match-property-value-ecmascript@2.2.1: + resolution: {integrity: sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==} + engines: {node: '>=4'} + + unicode-property-aliases-ecmascript@2.2.0: + resolution: {integrity: sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==} + engines: {node: '>=4'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + zod@4.6.1: + resolution: {integrity: sha512-341aRWQsve0rvronKNTqZpjmzdbUDlFuzHaI/XLg/Ej82qffDJRRfBTCuv7+9q/rMjB6LSLyEBnW4InJeMtt/Q==} + +snapshots: + + '@aws-sdk/core@3.978.0': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.33.3 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + bowser: 2.14.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-env@3.972.71': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-http@3.972.73': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-ini@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-login': 3.972.78 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-login@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-node@3.972.83': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-ini': 3.973.16 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-process@3.972.71': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-sso@3.973.15': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/token-providers': 3.1129.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-web-identity@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/nested-clients@3.997.45': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/token-providers@3.1129.0': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@aws/lambda-invoke-store@0.3.0': + optional: true + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.8 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + regexpu-core: 6.4.0 + semver: 6.3.1 + + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + debug: 4.4.3 + lodash.debounce: 4.0.8 + resolve: 1.22.12 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.8 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-wrap-function': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helper-wrap-function@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-proposal-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-flow@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-flow-strip-types@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-display-name@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7) + babel-plugin-polyfill-corejs3: 0.13.0(@babel/core@7.29.7) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.12 + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.6 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/semantic-conventions@1.43.0': + optional: true + + '@react-native/babel-plugin-codegen@0.87.1(@babel/core@7.29.7)': + dependencies: + '@babel/traverse': 7.29.8 + '@react-native/codegen': 0.87.1(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + '@react-native/babel-preset@0.87.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) + '@react-native/babel-plugin-codegen': 0.87.1(@babel/core@7.29.7) + babel-plugin-syntax-hermes-parser: 0.36.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + react-refresh: 0.14.2 + transitivePeerDependencies: + - supports-color + + '@react-native/codegen@0.87.1(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + hermes-parser: 0.36.1 + invariant: 2.2.4 + nullthrows: 1.1.1 + tinyglobby: 0.2.17 + yargs: 17.7.3 + + '@sinclair/typebox@0.27.12': {} + + '@smithy/core@3.33.3': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.33.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + optional: true + + '@smithy/types@4.18.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/node@25.9.6': + dependencies: + undici-types: 7.24.6 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7): + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-corejs3@0.13.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + core-js-compat: 3.50.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + babel-plugin-syntax-hermes-parser@0.36.1: + dependencies: + hermes-parser: 0.36.1 + + babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.29.7): + dependencies: + '@babel/plugin-syntax-flow': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - '@babel/core' + + baseline-browser-mapping@2.11.21: {} + + bowser@2.14.1: + optional: true + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.425 + node-releases: 2.0.55 + update-browserslist-db: 1.3.2(browserslist@4.28.7) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001810: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + ci-info@3.9.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@2.20.3: {} + + connect@3.7.0: + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + core-js-compat@3.50.0: + dependencies: + browserslist: 4.28.7 + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.425: {} + + emoji-regex@8.0.0: {} + + encodeurl@1.0.2: {} + + error-stack-parser@2.1.4: + dependencies: + stackframe: 1.3.4 + + es-errors@1.3.0: {} + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + eventemitter3@4.0.7: {} + + exponential-backoff@3.1.3: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@1.1.2: + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + flow-enums-runtime@0.0.6: {} + + flow-estree@0.331.0: {} + + flow-parser@0.331.0: + dependencies: + flow-estree: 0.331.0 + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.36.1: {} + + hermes-parser@0.36.1: + dependencies: + hermes-estree: 0.36.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + invariant@2.2.4: + dependencies: + loose-envify: 1.4.0 + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-fullwidth-code-point@3.0.0: {} + + is-number@7.0.0: {} + + jest-get-type@29.6.3: {} + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.9.6 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.9.6 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + js-tokens@4.0.0: {} + + jsc-safe-url@0.2.4: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.1))(ws@8.21.3): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.1) + ws: 8.21.3 + + leven@3.1.0: {} + + lodash.debounce@4.0.8: {} + + lodash.throttle@4.1.1: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + merge-stream@2.0.0: {} + + metro-babel-transformer@0.87.1: + dependencies: + '@babel/core': 7.29.7 + flow-enums-runtime: 0.0.6 + flow-parser: 0.331.0 + metro-cache-key: 0.87.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-cache-key@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-cache@0.87.1: + dependencies: + exponential-backoff: 3.1.3 + flow-enums-runtime: 0.0.6 + https-proxy-agent: 7.0.6 + metro-core: 0.87.1 + transitivePeerDependencies: + - supports-color + + metro-config@0.87.1: + dependencies: + connect: 3.7.0 + flow-enums-runtime: 0.0.6 + jest-validate: 29.7.0 + metro: 0.87.1 + metro-cache: 0.87.1 + metro-core: 0.87.1 + metro-runtime: 0.87.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro-core@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + lodash.throttle: 4.1.1 + metro-resolver: 0.87.1 + + metro-file-map@0.87.1: + dependencies: + debug: 4.4.3 + fb-watchman: 2.0.2 + flow-enums-runtime: 0.0.6 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.8 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-minify-terser@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + terser: 5.46.1 + + metro-resolver@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + + metro-runtime@0.87.1: + dependencies: + '@babel/runtime': 7.29.7 + flow-enums-runtime: 0.0.6 + + metro-source-map@0.87.1: + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-symbolicate: 0.87.1 + nullthrows: 1.1.1 + ob1: 0.87.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-symbolicate@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + metro-source-map: 0.87.1 + nullthrows: 1.1.1 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + + metro-transform-plugins@0.87.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + flow-enums-runtime: 0.0.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + + metro-transform-worker@0.87.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + flow-enums-runtime: 0.0.6 + metro: 0.87.1 + metro-babel-transformer: 0.87.1 + metro-cache: 0.87.1 + metro-cache-key: 0.87.1 + metro-minify-terser: 0.87.1 + metro-source-map: 0.87.1 + metro-transform-plugins: 0.87.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + metro@0.87.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + accepts: 2.0.0 + connect: 3.7.0 + debug: 4.4.3 + error-stack-parser: 2.1.4 + flow-enums-runtime: 0.0.6 + flow-parser: 0.331.0 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.87.1 + metro-cache: 0.87.1 + metro-cache-key: 0.87.1 + metro-config: 0.87.1 + metro-core: 0.87.1 + metro-file-map: 0.87.1 + metro-resolver: 0.87.1 + metro-runtime: 0.87.1 + metro-source-map: 0.87.1 + metro-symbolicate: 0.87.1 + metro-transform-plugins: 0.87.1 + metro-transform-worker: 0.87.1 + mime-types: 3.0.2 + nullthrows: 1.1.1 + serialize-error: 2.1.0 + source-map: 0.5.7 + throat: 5.0.0 + ws: 7.5.13 + yargs: 17.7.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.0.0: {} + + ms@2.1.3: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + node-int64@0.4.0: {} + + node-releases@2.0.55: {} + + nullthrows@1.1.1: {} + + ob1@0.87.1: + dependencies: + flow-enums-runtime: 0.0.6 + + on-finished@2.3.0: + dependencies: + ee-first: 1.1.1 + + openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.1): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.83 + '@smithy/signature-v4': 5.7.3 + ws: 8.21.3 + zod: 4.6.1 + optional: true + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + parseurl@1.3.3: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + react-is@18.3.1: {} + + react-refresh@0.14.2: {} + + regenerate-unicode-properties@10.2.2: + dependencies: + regenerate: 1.4.2 + + regenerate@1.4.2: {} + + regexpu-core@6.4.0: + dependencies: + regenerate: 1.4.2 + regenerate-unicode-properties: 10.2.2 + regjsgen: 0.8.0 + regjsparser: 0.13.2 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.2.1 + + regjsgen@0.8.0: {} + + regjsparser@0.13.2: + dependencies: + jsesc: 3.1.0 + + require-directory@2.1.1: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + semver@6.3.1: {} + + serialize-error@2.1.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.5.7: {} + + source-map@0.6.1: {} + + stackframe@1.3.4: {} + + statuses@1.5.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + terser@5.46.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + throat@5.0.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tslib@2.8.1: + optional: true + + undici-types@7.24.6: {} + + unicode-canonical-property-names-ecmascript@2.0.1: {} + + unicode-match-property-ecmascript@2.0.0: + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.1 + unicode-property-aliases-ecmascript: 2.2.0 + + unicode-match-property-value-ecmascript@2.2.1: {} + + unicode-property-aliases-ecmascript@2.2.0: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.3.2(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + utils-merge@1.0.1: {} + + vlq@1.0.1: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + ws@7.5.13: {} + + ws@8.21.3: + optional: true + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + zod@4.6.1: + optional: true diff --git a/internal/environment_tests/test-exports-metro/pnpm-workspace.yaml b/internal/environment_tests/test-exports-metro/pnpm-workspace.yaml new file mode 100644 index 0000000..7cb0fce --- /dev/null +++ b/internal/environment_tests/test-exports-metro/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +nodeLinker: hoisted +packages: [] diff --git a/internal/environment_tests/test-exports-metro/src/index.js b/internal/environment_tests/test-exports-metro/src/index.js new file mode 100644 index 0000000..42c6181 --- /dev/null +++ b/internal/environment_tests/test-exports-metro/src/index.js @@ -0,0 +1,34 @@ +/** + * Metro bundler test for langsmith exports. + * Tests that public API works correctly when bundled with Metro. + * + * This test verifies: + * 1. Metro can bundle langsmith + * 2. The browser field in package.json correctly swaps Node-specific code + * 3. Client export is available + * 4. Cache export is available (in-memory caching works in React Native) + * + * Note: Cache dump/load are not supported in React Native. + */ + +import { Client, PromptCache } from "langsmith"; + +// Export to prevent tree-shaking +export { Client, PromptCache }; + +// Simple test that instantiation works +export function testExports() { + const client = new Client({ apiKey: "test-key" }); + + // Test Cache - in-memory caching works in React Native + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + cache.stop(); + + // Test Client with cache enabled + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + + return { client, cache, clientWithCache }; +} diff --git a/internal/environment_tests/test-exports-vite/package.json b/internal/environment_tests/test-exports-vite/package.json new file mode 100644 index 0000000..a6adc00 --- /dev/null +++ b/internal/environment_tests/test-exports-vite/package.json @@ -0,0 +1,16 @@ +{ + "name": "test-exports-vite", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Tests that langsmith exports work correctly in Vite browser builds", + "scripts": { + "test": "vite build" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "vite": "^8.3.0" + } +} diff --git a/internal/environment_tests/test-exports-vite/pnpm-lock.yaml b/internal/environment_tests/test-exports-vite/pnpm-lock.yaml new file mode 100644 index 0000000..a363c2a --- /dev/null +++ b/internal/environment_tests/test-exports-vite/pnpm-lock.yaml @@ -0,0 +1,1060 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + devDependencies: + vite: + specifier: ^8.3.0 + version: 8.3.0(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.9.0) + +packages: + + '@aws-sdk/core@3.977.4': + resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} + engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. + + '@aws-sdk/credential-provider-env@3.972.65': + resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.67': + resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.10': + resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.72': + resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.76': + resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.65': + resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.9': + resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.71': + resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.39': + resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1100.0': + resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@oxc-project/types@0.150.0': + resolution: {integrity: sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==} + + '@rolldown/binding-android-arm-eabi@1.2.9': + resolution: {integrity: sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.9': + resolution: {integrity: sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.9': + resolution: {integrity: sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.9': + resolution: {integrity: sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.9': + resolution: {integrity: sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + resolution: {integrity: sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + resolution: {integrity: sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.9': + resolution: {integrity: sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + resolution: {integrity: sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + resolution: {integrity: sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.9': + resolution: {integrity: sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.9': + resolution: {integrity: sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.9': + resolution: {integrity: sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + resolution: {integrity: sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.9': + resolution: {integrity: sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.9: + resolution: {integrity: sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.3.0: + resolution: {integrity: sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@aws-sdk/core@3.977.4': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-env@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-http@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-ini@3.973.10': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-login': 3.972.72 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-login@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-node@3.972.76': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-ini': 3.973.10 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-process@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-sso@3.973.9': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/token-providers': 3.1100.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-web-identity@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/nested-clients@3.997.39': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/token-providers@3.1100.0': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws/lambda-invoke-store@0.3.0': + optional: true + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/semantic-conventions@1.43.0': + optional: true + + '@oxc-project/types@0.150.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.9': + optional: true + + '@rolldown/binding-android-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.9': + optional: true + + '@rolldown/binding-darwin-x64@1.2.9': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.9': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.9': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.9': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.9': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.9': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.9': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.9': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + optional: true + + bowser@2.14.1: + optional: true + + detect-libc@2.1.2: {} + + eventemitter3@4.0.7: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + jiti@2.7.0: + optional: true + + langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3) + ws: 8.21.1 + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + nanoid@3.3.19: {} + + openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.76 + '@smithy/signature-v4': 5.6.12 + ws: 8.21.1 + zod: 4.4.3 + optional: true + + p-finally@1.0.0: {} + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.9: + dependencies: + '@oxc-project/types': 0.150.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.9 + '@rolldown/binding-android-arm64': 1.2.9 + '@rolldown/binding-darwin-arm64': 1.2.9 + '@rolldown/binding-darwin-x64': 1.2.9 + '@rolldown/binding-freebsd-x64': 1.2.9 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.9 + '@rolldown/binding-linux-arm64-gnu': 1.2.9 + '@rolldown/binding-linux-arm64-musl': 1.2.9 + '@rolldown/binding-linux-ppc64-gnu': 1.2.9 + '@rolldown/binding-linux-s390x-gnu': 1.2.9 + '@rolldown/binding-linux-x64-gnu': 1.2.9 + '@rolldown/binding-linux-x64-musl': 1.2.9 + '@rolldown/binding-openharmony-arm64': 1.2.9 + '@rolldown/binding-win32-arm64-msvc': 1.2.9 + '@rolldown/binding-win32-x64-msvc': 1.2.9 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tslib@2.8.1: + optional: true + + undici-types@7.24.6: + optional: true + + vite@8.3.0(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.9.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.9 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.5 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.0 + + ws@8.21.1: + optional: true + + yaml@2.9.0: + optional: true + + zod@4.4.3: + optional: true diff --git a/internal/environment_tests/test-exports-vite/pnpm-workspace.yaml b/internal/environment_tests/test-exports-vite/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-vite/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-vite/src/index.ts b/internal/environment_tests/test-exports-vite/src/index.ts new file mode 100644 index 0000000..7f91f6b --- /dev/null +++ b/internal/environment_tests/test-exports-vite/src/index.ts @@ -0,0 +1,37 @@ +/** + * Vite browser environment test for langsmith exports. + * Tests that public API works correctly when bundled for browsers. + * + * This test verifies: + * 1. Vite can build langsmith for browser targets + * 2. The browser field in package.json correctly swaps Node-specific code + * 3. Client export is available (uses fetch, works in browsers) + * 4. Cache export is available (in-memory caching works in browsers) + * + * Note: traceable and RunTree use AsyncLocalStorage from node:async_hooks + * which doesn't have a browser equivalent, so they're not tested here. + * Note: Cache dump/load are not supported in browsers. + */ + +import { Client, PromptCache } from "langsmith"; + +// Export to prevent tree-shaking +export { Client, PromptCache }; + +// Simple test that instantiation works +export function testExports() { + // Test Client - should work in browser (API calls use fetch) + const client = new Client({ apiKey: "test-key" }); + + // Test Cache - in-memory caching works in browsers + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + cache.stop(); + + // Test Client with cache enabled + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + + return { client, cache, clientWithCache }; +} diff --git a/internal/environment_tests/test-exports-vite/vite.config.ts b/internal/environment_tests/test-exports-vite/vite.config.ts new file mode 100644 index 0000000..3f44600 --- /dev/null +++ b/internal/environment_tests/test-exports-vite/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + // Build as a library to test that imports work + lib: { + entry: "src/index.ts", + formats: ["es"], + fileName: "index", + }, + // Don't minify so we can inspect output if needed + minify: false, + // Output to dist + outDir: "dist", + }, +}); diff --git a/internal/environment_tests/test-exports-webpack/package.json b/internal/environment_tests/test-exports-webpack/package.json new file mode 100644 index 0000000..24b6104 --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/package.json @@ -0,0 +1,21 @@ +{ + "name": "test-exports-webpack", + "version": "0.0.0", + "private": true, + "description": "Tests that langsmith exports work correctly in webpack browser builds", + "scripts": { + "test": "webpack --mode production" + }, + "dependencies": { + "langsmith": "file:../../../dist" + }, + "devDependencies": { + "webpack": "^5.90.0", + "webpack-cli": "^5.1.0" + }, + "pnpm": { + "overrides": { + "fast-uri": "3.1.8" + } + } +} diff --git a/internal/environment_tests/test-exports-webpack/pnpm-lock.yaml b/internal/environment_tests/test-exports-webpack/pnpm-lock.yaml new file mode 100644 index 0000000..7b4de25 --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/pnpm-lock.yaml @@ -0,0 +1,1532 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + fast-uri: 3.1.8 + +importers: + + .: + dependencies: + langsmith: + specifier: file:../../../dist + version: file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1) + devDependencies: + webpack: + specifier: ^5.90.0 + version: 5.109.2(webpack-cli@5.1.4) + webpack-cli: + specifier: ^5.1.0 + version: 5.1.4(webpack@5.109.2) + +packages: + + '@aws-sdk/core@3.977.4': + resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} + engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. + + '@aws-sdk/credential-provider-env@3.972.65': + resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.67': + resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.10': + resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.72': + resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.76': + resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.65': + resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.9': + resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.71': + resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.39': + resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1100.0': + resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@discoveryjs/json-ext@0.5.7': + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.10.0': + resolution: {integrity: sha512-/wNZ8twnEQQA4HoHu22+vcsdru6pWPWxW+7w+FlxT6Id7PE/WIbZmVKkte+PF72e0F2dnImFeHD2syyE1Mw6MQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.10.0': + resolution: {integrity: sha512-q6MMm2zhggzsHVNbabYwut+a6nbuQQe3URUoxaojM/8K1IBfwwPzvxIjNi2/lI1TFe+fMHMW9MWhrtDLEXEnkA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.10.0': + resolution: {integrity: sha512-GuYQQT7QD2EeO8lcZLRQzcbOyhqAzL+6WWTKTU9mSUBYBazkEDl+VrQcXQhbB08OWM9anD1aHleVadzulpOaUQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.10.0': + resolution: {integrity: sha512-MfQGq3GRmTh5fM/y+OjaO0vj6+luCB1XO2gfXCalKCfgKw0eHL++sm75DNweC6ohlp+aFvACqeE0fYayqdRaoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@25.9.5': + resolution: {integrity: sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@webpack-cli/configtest@2.1.1': + resolution: {integrity: sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/info@2.0.2': + resolution: {integrity: sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + + '@webpack-cli/serve@2.0.5': + resolution: {integrity: sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==} + engines: {node: '>=14.15.0'} + peerDependencies: + webpack: 5.x.x + webpack-cli: 5.x.x + webpack-dev-server: '*' + peerDependenciesMeta: + webpack-dev-server: + optional: true + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + baseline-browser-mapping@2.11.11: + resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + + colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} + + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + + envinfo@7.21.0: + resolution: {integrity: sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==} + engines: {node: '>=4'} + hasBin: true + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + interpret@3.1.1: + resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} + engines: {node: '>=10.13.0'} + + is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + langsmith@file:../../../dist: + resolution: {directory: ../../../dist, type: directory} + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + minimizer-webpack-plugin@5.6.1: + resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + openai@6.49.0: + resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==} + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-queue@6.6.2: + resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} + engines: {node: '>=8'} + + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + rechoir@0.8.0: + resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} + engines: {node: '>= 10.13.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + resolve@1.22.12: + resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} + engines: {node: '>= 0.4'} + hasBin: true + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser@5.46.1: + resolution: {integrity: sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==} + engines: {node: '>=10'} + hasBin: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + webpack-cli@5.1.4: + resolution: {integrity: sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==} + engines: {node: '>=14.15.0'} + hasBin: true + peerDependencies: + '@webpack-cli/generators': '*' + webpack: 5.x.x + webpack-bundle-analyzer: '*' + webpack-dev-server: '*' + peerDependenciesMeta: + '@webpack-cli/generators': + optional: true + webpack-bundle-analyzer: + optional: true + webpack-dev-server: + optional: true + + webpack-merge@5.10.0: + resolution: {integrity: sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==} + engines: {node: '>=10.0.0'} + + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + engines: {node: '>=10.13.0'} + + webpack@5.109.2: + resolution: {integrity: sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wildcard@2.0.1: + resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@aws-sdk/core@3.977.4': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-env@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-http@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-ini@3.973.10': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-login': 3.972.72 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-login@3.972.72': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-node@3.972.76': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-ini': 3.973.10 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-process@3.972.65': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-sso@3.973.9': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/token-providers': 3.1100.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/credential-provider-web-identity@3.972.71': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/nested-clients@3.997.39': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/token-providers@3.1100.0': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@aws/lambda-invoke-store@0.3.0': + optional: true + + '@discoveryjs/json-ext@0.5.7': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + optional: true + + '@opentelemetry/api@1.9.1': + optional: true + + '@opentelemetry/core@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/resources@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + optional: true + + '@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/sdk-trace@2.10.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.10.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + optional: true + + '@opentelemetry/semantic-conventions@1.43.0': + optional: true + + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + optional: true + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@25.9.5': + dependencies: + undici-types: 7.24.6 + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@webpack-cli/configtest@2.1.1(webpack-cli@5.1.4)(webpack@5.109.2)': + dependencies: + webpack: 5.109.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.109.2) + + '@webpack-cli/info@2.0.2(webpack-cli@5.1.4)(webpack@5.109.2)': + dependencies: + webpack: 5.109.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.109.2) + + '@webpack-cli/serve@2.0.5(webpack-cli@5.1.4)(webpack@5.109.2)': + dependencies: + webpack: 5.109.2(webpack-cli@5.1.4) + webpack-cli: 5.1.4(webpack@5.109.2) + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + acorn@8.18.0: {} + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + baseline-browser-mapping@2.11.11: {} + + bowser@2.14.1: + optional: true + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.11 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.399 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + buffer-from@1.1.2: {} + + caniuse-lite@1.0.30001806: {} + + chrome-trace-event@1.0.4: {} + + clone-deep@4.0.1: + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + colorette@2.0.20: {} + + commander@10.0.1: {} + + commander@2.20.3: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + electron-to-chromium@1.5.399: {} + + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + envinfo@7.21.0: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.1: {} + + escalade@3.2.0: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + eventemitter3@4.0.7: {} + + events@3.3.0: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.8: {} + + fastest-levenshtein@1.0.16: {} + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + flat@5.0.2: {} + + function-bind@1.1.2: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + interpret@3.1.1: {} + + is-core-module@2.16.2: + dependencies: + hasown: 2.0.4 + + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + + isexe@2.0.0: {} + + isobject@3.0.1: {} + + jest-worker@27.5.1: + dependencies: + '@types/node': 25.9.5 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + json-schema-traverse@1.0.0: {} + + kind-of@6.0.3: {} + + langsmith@file:../../../dist(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.10.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3))(ws@8.21.1): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.10.0(@opentelemetry/api@1.9.1) + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3) + ws: 8.21.1 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + merge-stream@2.0.0: {} + + mime-db@1.54.0: {} + + minimizer-webpack-plugin@5.6.1(webpack@5.109.2): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.46.1 + webpack: 5.109.2(webpack-cli@5.1.4) + + neo-async@2.6.2: {} + + node-releases@2.0.51: {} + + openai@6.49.0(@aws-sdk/credential-provider-node@3.972.76)(@smithy/signature-v4@5.6.12)(ws@8.21.1)(zod@4.4.3): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.76 + '@smithy/signature-v4': 5.6.12 + ws: 8.21.1 + zod: 4.4.3 + optional: true + + p-finally@1.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-try@2.2.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + rechoir@0.8.0: + dependencies: + resolve: 1.22.12 + + require-from-string@2.0.2: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + resolve@1.22.12: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.2 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + shallow-clone@3.0.1: + dependencies: + kind-of: 6.0.3 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tapable@2.3.3: {} + + terser@5.46.1: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + tslib@2.8.1: + optional: true + + undici-types@7.24.6: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + webpack-cli@5.1.4(webpack@5.109.2): + dependencies: + '@discoveryjs/json-ext': 0.5.7 + '@webpack-cli/configtest': 2.1.1(webpack-cli@5.1.4)(webpack@5.109.2) + '@webpack-cli/info': 2.0.2(webpack-cli@5.1.4)(webpack@5.109.2) + '@webpack-cli/serve': 2.0.5(webpack-cli@5.1.4)(webpack@5.109.2) + colorette: 2.0.20 + commander: 10.0.1 + cross-spawn: 7.0.6 + envinfo: 7.21.0 + fastest-levenshtein: 1.0.16 + import-local: 3.2.0 + interpret: 3.1.1 + rechoir: 0.8.0 + webpack: 5.109.2(webpack-cli@5.1.4) + webpack-merge: 5.10.0 + + webpack-merge@5.10.0: + dependencies: + clone-deep: 4.0.1 + flat: 5.0.2 + wildcard: 2.0.1 + + webpack-sources@3.5.1: {} + + webpack@5.109.2(webpack-cli@5.1.4): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.18.0 + browserslist: 4.28.7 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.5 + es-module-lexer: 2.3.1 + eslint-scope: 5.1.1 + events: 3.3.0 + graceful-fs: 4.2.11 + mime-db: 1.54.0 + minimizer-webpack-plugin: 5.6.1(webpack@5.109.2) + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + watchpack: 2.5.2 + webpack-sources: 3.5.1 + optionalDependencies: + webpack-cli: 5.1.4(webpack@5.109.2) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wildcard@2.0.1: {} + + ws@8.21.1: + optional: true + + zod@4.4.3: + optional: true diff --git a/internal/environment_tests/test-exports-webpack/pnpm-workspace.yaml b/internal/environment_tests/test-exports-webpack/pnpm-workspace.yaml new file mode 100644 index 0000000..7842cc4 --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +confirmModulesPurge: false +minimumReleaseAge: 1440 +packages: [] diff --git a/internal/environment_tests/test-exports-webpack/src/index.js b/internal/environment_tests/test-exports-webpack/src/index.js new file mode 100644 index 0000000..3729704 --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/src/index.js @@ -0,0 +1,34 @@ +/** + * Webpack browser environment test for langsmith exports. + * Tests that public API works correctly when bundled with webpack for browsers. + * + * This test verifies: + * 1. Webpack can build langsmith for browser targets + * 2. The browser field in package.json correctly swaps Node-specific code + * 3. Client export is available (uses fetch, works in browsers) + * 4. Cache export is available (in-memory caching works in browsers) + * + * Note: Cache dump/load are not supported in browsers. + */ + +import { Client, PromptCache } from "langsmith"; + +// Export to prevent tree-shaking +export { Client, PromptCache }; + +// Simple test that instantiation works +export function testExports() { + const client = new Client({ apiKey: "test-key" }); + + // Test Cache - in-memory caching works in browsers + const cache = new PromptCache({ maxSize: 100, ttlSeconds: 3600 }); + cache.stop(); + + // Test Client with cache enabled + const clientWithCache = new Client({ + apiKey: "test-key", + disablePromptCache: false, + }); + + return { client, cache, clientWithCache }; +} diff --git a/internal/environment_tests/test-exports-webpack/tsconfig.json b/internal/environment_tests/test-exports-webpack/tsconfig.json new file mode 100644 index 0000000..875cb60 --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/tsconfig.json @@ -0,0 +1,3 @@ +{ + "compilerOptions": {} +} diff --git a/internal/environment_tests/test-exports-webpack/webpack.config.js b/internal/environment_tests/test-exports-webpack/webpack.config.js new file mode 100644 index 0000000..5863cda --- /dev/null +++ b/internal/environment_tests/test-exports-webpack/webpack.config.js @@ -0,0 +1,19 @@ +const path = require("path"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "bundle.js", + path: path.resolve(__dirname, "dist"), + library: { + type: "module", + }, + }, + experiments: { + outputModule: true, + }, + target: "web", // Explicitly target browser + resolve: { + extensions: [".js"], + }, +}; diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..30b3b87 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,66 @@ +import type { Config } from 'jest'; + +const config: Config = { + projects: [ + { + displayName: 'stainless', + testEnvironment: 'node', + transform: { + '^.+\\.(t|j)sx?$': ['@swc/jest', { sourceMaps: 'inline' }], + }, + moduleNameMapper: { + '^langsmith$': '/src/index.ts', + '^langsmith/(.*)$': '/src/$1', + // src/lib (reached through src/index.ts) uses `.js`-suffixed relative imports + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + modulePathIgnorePatterns: [ + '/ecosystem-tests/', + '/dist/', + '/deno/', + '/deno_tests/', + '/packages/', + ], + testMatch: ['/tests/**/*.test.ts'], + testPathIgnorePatterns: ['scripts'], + }, + { + // langsmith-sdk's jest.config.cjs, for the hand-written code under src/lib. + // Runs as ESM: scripts/test sets NODE_OPTIONS=--experimental-vm-modules. + displayName: 'lib', + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['/src/lib/tests/**/*.test.ts'], + modulePathIgnorePatterns: ['/dist/'], + testPathIgnorePatterns: [ + '\\.int\\.test\\.[tj]s$', + '\\.vitesttest\\.', + '\\.vitesteval\\.', + 'cache.*\\.int\\.test\\.ts$', + 'rate_limit.*\\.int\\.test\\.ts$', + 'ai_sdk_evaluate\\.int\\.test\\.ts$', + ], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.m?[tj]sx?$': [ + 'ts-jest', + { + useESM: true, + diagnostics: false, + isolatedModules: true, + tsconfig: '/tsconfig.esm.json', + }, + ], + }, + setupFiles: ['dotenv/config'], + setupFilesAfterEnv: ['/jest.setup.cjs'], + }, + ], + // langsmith-sdk's `pnpm test` passes --testTimeout 30000 + testTimeout: 30_000, + maxConcurrency: 2, +}; + +export default config; diff --git a/jest.setup.cjs b/jest.setup.cjs new file mode 100644 index 0000000..9be80ab --- /dev/null +++ b/jest.setup.cjs @@ -0,0 +1 @@ +jest.retryTimes(3, { logErrorsBeforeRetry: true }); diff --git a/ls.vitest.config.ts b/ls.vitest.config.ts new file mode 100644 index 0000000..de2c1f1 --- /dev/null +++ b/ls.vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.vitesteval.?(c|m)[jt]s"], + reporters: ["./src/lib/vitest/reporter.mts"], + setupFiles: ["dotenv/config"], + hookTimeout: 30000, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..7a94c91 --- /dev/null +++ b/package.json @@ -0,0 +1,221 @@ +{ + "name": "langsmith", + "version": "0.10.4", + "description": "Client library to connect to the LangSmith Observability and Evaluation Platform.", + "author": "LangChain <>", + "types": "dist/index.d.ts", + "main": "dist/index.js", + "type": "commonjs", + "repository": "github:langchain-ai/langsmith-javascript", + "license": "MIT", + "packageManager": "pnpm@10.30.1", + "files": [ + "**/*" + ], + "private": false, + "publishConfig": { + "access": "public" + }, + "scripts": { + "test": "./scripts/test", + "build": "./scripts/build", + "prepublishOnly": "echo 'to publish, run pnpm build && (cd dist; pnpm publish)' && exit 1", + "format": "./scripts/format", + "prepare": "if ./scripts/utils/check-is-in-git-install.sh; then ./scripts/build && ./scripts/utils/git-swap.sh; fi", + "tsn": "ts-node -r tsconfig-paths/register", + "lint": "./scripts/lint", + "fix": "./scripts/format", + "test:integration": "NODE_OPTIONS=--experimental-vm-modules ./node_modules/.bin/jest --selectProjects lib --testPathIgnorePatterns='cache.*\\.int\\.test\\.ts$' 'rate_limit.*\\.int\\.test\\.ts$' 'ai_sdk_evaluate\\.int\\.test\\.ts$' --testPathPattern='\\.int\\.test\\.ts$' --testTimeout 100000", + "test:vitest": "vitest run --config vitest.config.ts", + "test:eval:vitest": "vitest run --config ls.vitest.config.ts", + "build:typedoc": "pnpm build && rm -rf ./_build/api_refs && npx typedoc" + }, + "dependencies": { + "p-queue": "6.6.2" + }, + "devDependencies": { + "@ai-sdk/anthropic": "4.0.18", + "@ai-sdk/harness": "1.0.41", + "@ai-sdk/harness-claude-code": "1.0.42", + "@ai-sdk/harness-pi": "1.0.41", + "@ai-sdk/openai": "4.0.4", + "@ai-sdk/provider": "4.0.1", + "@ai-sdk/sandbox-just-bash": "1.0.40", + "@ai-sdk/sandbox-vercel": "1.0.41", + "@anthropic-ai/claude-agent-sdk": "^0.3.150", + "@anthropic-ai/sdk": "^0.98.0", + "@arethetypeswrong/cli": "^0.18.5", + "@earendil-works/pi-coding-agent": "0.80.10", + "@faker-js/faker": "^10.5.0", + "@google/genai": "^2.0.1", + "@jest/globals": "^30.4.1", + "@jest/reporters": "^30.2.0", + "@langchain/core": "^0.3.72", + "@langchain/langgraph": "^0.3.6", + "@langchain/openai": "^0.6.17", + "@openai/agents": "^0.12.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/auto-instrumentations-node": "^0.80.0", + "@opentelemetry/context-async-hooks": "^2.9.0", + "@opentelemetry/sdk-node": "^0.222.0", + "@opentelemetry/sdk-trace-base": "^2.9.0", + "@opentelemetry/sdk-trace-node": "^2.9.0", + "@swc/core": "^1.3.102", + "@swc/jest": "^0.2.29", + "@types/jest": "^29.4.0", + "@types/node": "^25.9.1", + "@types/node-fetch": "^2.6.12", + "@types/semver": "^7.7.1", + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "8.31.1", + "@typescript-eslint/parser": "8.31.1", + "ai": "7.0.36", + "dotenv": "^17.3.1", + "eslint": "^9.39.1", + "eslint-plugin-unused-imports": "^4.1.4", + "iconv-lite": "^0.6.3", + "jest": "^29.4.0", + "langchain": "^0.3.29", + "mongoose": "^9.5.0", + "msw": "^2.11.2", + "node-fetch": "^3.3.2", + "openai": "^6.18.0", + "prettier": "^3.0.0", + "publint": "^0.2.12", + "semver": "^7.7.4", + "ts-jest": "^29.1.0", + "ts-node": "^10.5.0", + "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz", + "tsconfig-paths": "^4.0.0", + "tslib": "^2.8.1", + "typedoc": "^0.28.16", + "typedoc-plugin-expand-object-like-types": "^0.1.2", + "typescript": "5.8.3", + "typescript-eslint": "8.31.1", + "vitest": "^4.1.11", + "ws": "^8.19.0", + "zod": "^4.3.6" + }, + "overrides": { + "minimatch": "^9.0.7", + "@hono/node-server": "2.0.10", + "brace-expansion@<1.1.18": "1.1.18", + "brace-expansion@>=2.0.0 <2.1.4": "2.1.4", + "brace-expansion@>=3.0.0 <5.0.7": "5.0.7", + "fast-uri": "3.1.6", + "postcss": "8.5.23", + "js-yaml": "4.3.2", + "hono": "4.13.5", + "ip-address": "10.3.1", + "undici@>=7.0.0 <8.0.0": "7.29.0", + "undici@>=8.0.0 <9.0.0": "8.9.0", + "vite": "^7.3.2", + "form-data": "4.0.6", + "langsmith": "0.6.0", + "@babel/core": "^7.29.7", + "esbuild": "^0.28.1", + "markdown-it": "^14.3.0", + "uuid": "^11.1.1" + }, + "pnpm": { + "overrides": { + "minimatch": "^9.0.7", + "@hono/node-server": "2.0.10", + "brace-expansion@<1.1.18": "1.1.18", + "brace-expansion@>=2.0.0 <2.1.4": "2.1.4", + "brace-expansion@>=3.0.0 <5.0.7": "5.0.7", + "fast-uri": "3.1.8", + "postcss": "8.5.23", + "js-yaml": "4.3.2", + "hono": "4.13.5", + "ip-address": "10.7.1", + "undici@>=7.0.0 <8.0.0": "7.29.1", + "undici@>=8.0.0 <9.0.0": "8.10.2", + "vite": "^7.3.2", + "form-data": "4.0.6", + "langsmith": "0.6.0", + "@babel/core": "^7.29.7", + "esbuild": "^0.28.1", + "markdown-it": "^14.3.0", + "uuid": "^11.1.1" + } + }, + "resolutions": { + "minimatch": "^9.0.7", + "@hono/node-server": "2.0.10", + "brace-expansion@<1.1.18": "1.1.18", + "brace-expansion@>=2.0.0 <2.1.4": "2.1.4", + "brace-expansion@>=3.0.0 <5.0.7": "5.0.7", + "fast-uri": "3.1.6", + "postcss": "8.5.23", + "js-yaml": "4.3.2", + "hono": "4.13.5", + "ip-address": "10.3.1", + "undici@>=7.0.0 <8.0.0": "7.29.0", + "undici@>=8.0.0 <9.0.0": "8.9.0", + "vite": "^7.3.2", + "form-data": "4.0.6", + "langsmith": "0.6.0", + "@babel/core": "^7.29.7", + "esbuild": "^0.28.1", + "markdown-it": "^14.3.0", + "uuid": "^11.1.1" + }, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js" + }, + "./client": { + "import": "./dist/lib/client.mjs", + "require": "./dist/lib/client.js" + }, + "./package.json": "./package.json", + "./*.mjs": { + "default": "./dist/*.mjs" + }, + "./*.js": { + "default": "./dist/*.js" + }, + "./*": { + "import": "./dist/*.mjs", + "require": "./dist/*.js" + } + }, + "keywords": [ + "LLM", + "Tracing", + "Evaluation", + "LangChain" + ], + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*", + "ws": ">=7" + }, + "peerDependenciesMeta": { + "openai": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "ws": { + "optional": true + } + }, + "browser": { + "./dist/lib/utils/fs.js": "./dist/lib/utils/fs.browser.js", + "./dist/lib/utils/worker_threads.js": "./dist/lib/utils/worker_threads.browser.js", + "./dist/lib/utils/fs.mjs": "./dist/lib/utils/fs.browser.mjs", + "./dist/lib/utils/worker_threads.mjs": "./dist/lib/utils/worker_threads.browser.mjs" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..149bc4b --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,14585 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + minimatch: ^9.0.7 + '@hono/node-server': 2.0.10 + brace-expansion@<1.1.18: 1.1.18 + brace-expansion@>=2.0.0 <2.1.4: 2.1.4 + brace-expansion@>=3.0.0 <5.0.7: 5.0.7 + fast-uri: 3.1.8 + postcss: 8.5.23 + js-yaml: 4.3.2 + hono: 4.13.5 + ip-address: 10.7.1 + undici@>=7.0.0 <8.0.0: 7.29.1 + undici@>=8.0.0 <9.0.0: 8.10.2 + vite: ^7.3.2 + form-data: 4.0.6 + langsmith: 0.6.0 + '@babel/core': ^7.29.7 + esbuild: ^0.28.1 + markdown-it: ^14.3.0 + uuid: ^11.1.1 + +importers: + .: + dependencies: + '@opentelemetry/exporter-trace-otlp-proto': + specifier: '*' + version: 0.222.0(@opentelemetry/api@1.9.1) + p-queue: + specifier: 6.6.2 + version: 6.6.2 + devDependencies: + '@ai-sdk/anthropic': + specifier: 4.0.18 + version: 4.0.18(zod@4.6.5) + '@ai-sdk/harness': + specifier: 1.0.41 + version: 1.0.41(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/harness-claude-code': + specifier: 1.0.42 + version: 1.0.42(zod@4.6.5) + '@ai-sdk/harness-pi': + specifier: 1.0.41 + version: 1.0.41(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/openai': + specifier: 4.0.4 + version: 4.0.4(zod@4.6.5) + '@ai-sdk/provider': + specifier: 4.0.1 + version: 4.0.1 + '@ai-sdk/sandbox-just-bash': + specifier: 1.0.40 + version: 1.0.40(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/sandbox-vercel': + specifier: 1.0.41 + version: 1.0.41(ws@8.21.3)(zod@4.6.5) + '@anthropic-ai/claude-agent-sdk': + specifier: ^0.3.150 + version: 0.3.278(@anthropic-ai/sdk@0.98.1(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(zod@4.6.5) + '@anthropic-ai/sdk': + specifier: ^0.98.0 + version: 0.98.1(zod@4.6.5) + '@arethetypeswrong/cli': + specifier: ^0.18.5 + version: 0.18.5 + '@earendil-works/pi-coding-agent': + specifier: 0.80.10 + version: 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + '@faker-js/faker': + specifier: ^10.5.0 + version: 10.6.0 + '@google/genai': + specifier: ^2.0.1 + version: 2.23.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) + '@jest/globals': + specifier: ^30.4.1 + version: 30.5.2 + '@jest/reporters': + specifier: ^30.2.0 + version: 30.5.2 + '@langchain/core': + specifier: ^0.3.72 + version: 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/langgraph': + specifier: ^0.3.6 + version: 0.3.12(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(zod-to-json-schema@3.25.2(zod@4.6.5)) + '@langchain/openai': + specifier: ^0.6.17 + version: 0.6.17(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(ws@8.21.3) + '@openai/agents': + specifier: ^0.12.0 + version: 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@opentelemetry/auto-instrumentations-node': + specifier: ^0.80.0 + version: 0.80.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/context-async-hooks': + specifier: ^2.9.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': + specifier: ^0.222.0 + version: 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': + specifier: ^2.9.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': + specifier: ^2.9.0 + version: 2.11.0(@opentelemetry/api@1.9.1) + '@swc/core': + specifier: ^1.3.102 + version: 1.4.16 + '@swc/jest': + specifier: ^0.2.29 + version: 0.2.36(@swc/core@1.4.16) + '@types/jest': + specifier: ^29.4.0 + version: 29.5.11 + '@types/node': + specifier: ^25.9.1 + version: 25.9.8 + '@types/node-fetch': + specifier: ^2.6.12 + version: 2.6.13 + '@types/semver': + specifier: ^7.7.1 + version: 7.8.0 + '@types/ws': + specifier: ^8.18.1 + version: 8.18.1 + '@typescript-eslint/eslint-plugin': + specifier: 8.31.1 + version: 8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/parser': + specifier: 8.31.1 + version: 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + ai: + specifier: 7.0.36 + version: 7.0.36(zod@4.6.5) + dotenv: + specifier: ^17.3.1 + version: 17.4.2 + eslint: + specifier: ^9.39.1 + version: 9.39.1(jiti@2.7.0) + eslint-plugin-unused-imports: + specifier: ^4.1.4 + version: 4.1.4(@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0)) + iconv-lite: + specifier: ^0.6.3 + version: 0.6.3 + jest: + specifier: ^29.4.0 + version: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + langchain: + specifier: ^0.3.29 + version: 0.3.37(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(handlebars@4.7.9)(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + mongoose: + specifier: ^9.5.0 + version: 9.10.1(@mongodb-js/zstd@7.0.0) + msw: + specifier: ^2.11.2 + version: 2.15.0(@types/node@25.9.8)(typescript@5.8.3) + node-fetch: + specifier: ^3.3.2 + version: 3.3.2 + openai: + specifier: ^6.18.0 + version: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + prettier: + specifier: ^3.0.0 + version: 3.1.1 + publint: + specifier: ^0.2.12 + version: 0.2.12 + semver: + specifier: ^7.7.4 + version: 7.8.5 + ts-jest: + specifier: ^29.1.0 + version: 29.4.6(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.5.1)(jest@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)))(typescript@5.8.3) + ts-node: + specifier: ^10.5.0 + version: 10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3) + tsc-multi: + specifier: https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz + version: https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz(typescript@5.8.3) + tsconfig-paths: + specifier: ^4.0.0 + version: 4.2.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typedoc: + specifier: ^0.28.16 + version: 0.28.20(typescript@5.8.3) + typedoc-plugin-expand-object-like-types: + specifier: ^0.1.2 + version: 0.1.2(typedoc@0.28.20(typescript@5.8.3)) + typescript: + specifier: 5.8.3 + version: 5.8.3 + typescript-eslint: + specifier: 8.31.1 + version: 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + vitest: + specifier: ^4.1.11 + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(msw@2.15.0(@types/node@25.9.8)(typescript@5.8.3))(vite@7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1)) + ws: + specifier: ^8.19.0 + version: 8.21.3 + zod: + specifier: ^4.3.6 + version: 4.6.5 + +packages: + '@ai-sdk/anthropic@4.0.18': + resolution: + { + integrity: sha512-CpSBDI7DWk3DHM+qwbjhH7JxqqEqr5sK/SDrC9eit8Q03Uf1xBBrgMAVf9FajbpaH3oHxynLn474lf6ikjjO8A==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/gateway@4.0.27': + resolution: + { + integrity: sha512-gqTMvV0N8/JirIZ3OzwjSZRYxzwZu/PeOFCKb8NB9fstWH39tI+L6CkeMNVou5/HCKEYAw6RCOHW59Vhquv8vA==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/harness-claude-code@1.0.42': + resolution: + { + integrity: sha512-diRYNHFJZp9Q7TjLLCBPq7/q0lshHfQvHqNFMc2m//4gBM+hlaWlHxs8VMIraUqU4QyA1BI5AKIBYfo9/nLaxw==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/harness-pi@1.0.41': + resolution: + { + integrity: sha512-HgAQH+NY1exDumXynXEfGyqJGp2EkTzAdU7C6MaRbic3QPOi1WTIziUEzvlmNYK/W+LgOmVF9w7Uk0cvp6aPZA==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/harness@1.0.40': + resolution: + { + integrity: sha512-LauzEpCdCCsKHInj3ndWoSfSHRaMejtUPDyuNDNX95bwB1x0HTpzfROOmQySN93wRd19NQaLcqGmVn4jWiJjgg==, + } + engines: { node: '>=22' } + peerDependencies: + ws: ^8.21.0 + zod: ^3.25.76 || ^4.1.8 + peerDependenciesMeta: + ws: + optional: true + + '@ai-sdk/harness@1.0.41': + resolution: + { + integrity: sha512-Z24TffprOTs9ueacxYxW+4r4dEKMtOrzzXZEJ2t09n55OJUB5Ttm/8Vr+jI9vVLNTOhhI0T/IFLo+4JdfaBx0g==, + } + engines: { node: '>=22' } + peerDependencies: + ws: ^8.21.0 + zod: ^3.25.76 || ^4.1.8 + peerDependenciesMeta: + ws: + optional: true + + '@ai-sdk/openai@4.0.4': + resolution: + { + integrity: sha512-p6VDEzEx52PIzRnFoUpiw6ABn07h4Rt+atL+M51W9SqwhselaZFRnz/pzv7LF9D1Gok5qfzOmR+JwvDDvDWS3w==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.12': + resolution: + { + integrity: sha512-bbhlOgHeYwrIGheLkM6fhS8hVger8uFPmcOLg+kxc9EFh7y30XYorWhthlYAgpadO3SJhFZrIcEknN7qEqEVvA==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider-utils@5.0.2': + resolution: + { + integrity: sha512-EcmdjJb7yggsZPCbS3MFBpvAUnKaPW+QvanU5GzF00XCq0bqqAmvJ3MN19ejlmOETbW8sJNiq6qam48wTcbUNw==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + '@ai-sdk/provider@4.0.1': + resolution: + { + integrity: sha512-6p3C/vGqVIjcptBu1DnVd/BZJ2wWmV9TUv9192vT6ZvT9KNED8EwRTqyqFpoQZKgSbMDSvBSq3dqR524Nt/Crw==, + } + engines: { node: '>=22' } + + '@ai-sdk/provider@4.0.3': + resolution: + { + integrity: sha512-e0CpNWJUY7OxAFAnCZkw+ri9QOHWwTs1tXP42782KFGCU07qt8NiXCrCVowyCB5dP2r5/Uls+g2oPd8kOJn9dw==, + } + engines: { node: '>=22' } + + '@ai-sdk/sandbox-just-bash@1.0.40': + resolution: + { + integrity: sha512-Ygz+oXJu9Y3nv2QFP1lDX0DpOmJNntffBPmCxVBxANDba8ybTyiandklLFaAmN6vs0GwkZEy3aqBNLDfXa1ZHg==, + } + engines: { node: '>=22' } + + '@ai-sdk/sandbox-vercel@1.0.41': + resolution: + { + integrity: sha512-fmttYJSjg+L4HDwJgHgDo5zq3RNp4uPkGRwsQKps0AeYHD4EE/UmbSv3P+5R8nCoxmHWaetE6+Iv6Ncc7mJuvA==, + } + engines: { node: '>=22' } + + '@andrewbranch/untar.js@1.0.3': + resolution: + { + integrity: sha512-Jh15/qVmrLGhkKJBdXlK1+9tY4lZruYjsgkDFj08ZmDiWVBLJcqkok7Z0/R0In+i1rScBpJlSvrTS2Lm41Pbnw==, + } + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.278': + resolution: + { + integrity: sha512-lwkbWfYucsgiFo6mWF3Dw7ImohlWXEkkh+4e93EpeVPCxwN4IJuLztFkl28qEwY17sZ9SYQw1mRksKauqKccqg==, + } + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.278': + resolution: + { + integrity: sha512-IYTWl9mVeXe1GnHPkpw04p+fZ23zgTBuxNXv06h7xgjSZ7zhudL05d6NpkY5T13m/zL5b+fyYkXxGciTXVhiUg==, + } + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.278': + resolution: + { + integrity: sha512-xbNx5TXiAr9REDh7znPnkvR8GoBe8A653hYXaVq+fNNLjXidOIvOhICMyUUrBC+wgZV1FenC46oSPIMH4n+EOQ==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.278': + resolution: + { + integrity: sha512-gt03vjRLsfnT3NyOaCVZFdM6V6p0NNGxhUr6e0Yfd9a28LlIeUtNB/kMtFJDibDtsjVi4Mp+Wj9R7dsXPLOFfw==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.278': + resolution: + { + integrity: sha512-OJ103bvIglKrp7LLS3Bpiy9ISnFIvUikcg5kJVqovA+uU/ElP96v3kCUli7nUL75TnguSL0PwdAb/rYujycLqQ==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.278': + resolution: + { + integrity: sha512-xbW4MkKyPMKdWzdA499WnAP9H7p8YlMsQCVEVyz6CemePZPSLwBP9GBeUTnSS57+MievEqj8knckJPVv5I3+yw==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.278': + resolution: + { + integrity: sha512-r75c3+Ymw4bn+If8LCoYLaYu5xXH8tWtP1xsd6KNtwda0KjFJA6/wmgicwCi3W+Vcdw6AfoFtYCQ0Sl1YwePwg==, + } + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.278': + resolution: + { + integrity: sha512-+7nMK4AYzeNFdqxYP80v2ialF6t29Z46rXeV6NF77xXc1+kqCm6hwOMFXhJkP8EEEclFatyjdU/Dij5vM5YSNA==, + } + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.278': + resolution: + { + integrity: sha512-hXfjyiGraqYul6gq7R7+WmktxJfy9S29Yrx1WK6jQpsYcipf1CYIoYSh22SpGOR3FGiEmeuzXg5U9ncOk19Fww==, + } + engines: { node: '>=18.0.0' } + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.91.1': + resolution: + { + integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==, + } + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@anthropic-ai/sdk@0.98.1': + resolution: + { + integrity: sha512-mqMQBMqAG0AoWStJEXrjDd3MrMyMiaIye9/5q2b3j5JxRF/2E7jgYlHSIJHiPj+4ztfsFH4Lcfj94WeXg2mAhg==, + } + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@arethetypeswrong/cli@0.18.5': + resolution: + { + integrity: sha512-gM+8vRsQOD/Uc7EnBedUhkG5OCsDWE4uoak5QvomGpMpaky0Eh41p04nIMgrWb8EOmqZUJGc6zz9hsP6E56R7g==, + } + engines: { node: '>=20' } + hasBin: true + + '@arethetypeswrong/core@0.18.5': + resolution: + { + integrity: sha512-9ytjzGwxjm9Uz7I9avfbt5vlQt6uk9uRRESzJjqrznl6WKvI6dwYTo+vJ3U02Wrq/mR3iql/PzhvHhKdJIAjDQ==, + } + engines: { node: '>=20' } + + '@aws-crypto/sha256-browser@5.2.0': + resolution: + { + integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==, + } + + '@aws-crypto/sha256-js@5.2.0': + resolution: + { + integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==, + } + engines: { node: '>=16.0.0' } + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: + { + integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==, + } + + '@aws-crypto/util@5.2.0': + resolution: + { + integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==, + } + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: + { + integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/core@3.978.0': + resolution: + { + integrity: sha512-2yX9LUmxPklVjSGTb8dfnWRJSiFQ3TeH2nn7G1mdKHTfnabzF0+gfrS8rYfLWmZrQ8A3mEcxMJjRc51dL5KWaA==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-env@3.972.71': + resolution: + { + integrity: sha512-JN+JHruYZw3GUZB8YGAlDk4wTDPOEAEEdEzj5nS0xodWR4smzHsN7PnK2j6IeOsDIj2aqua5DSbhXl9Gtf90FQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-http@3.972.73': + resolution: + { + integrity: sha512-uyYYnJOnlis8uQzaYGPd7N1JoioCoNpXgnkXYixsWJXHXgXyYi8WXJSDfofxJeWfQIGWLe2Nwyq60Uc7MZdVOg==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-ini@3.973.16': + resolution: + { + integrity: sha512-i++ly+0Uxa+u3ebSSyr0S/3CFhFJDxCXT3+Zj+mW2bXenEx5bKGCdTIKFu39SgXBNhWDjex/8cXUx9MUTMCrTw==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-login@3.972.78': + resolution: + { + integrity: sha512-eUtswnXu0+Ii9ieRK+0L7aPFV3Z/dnW2VntJzjBP9xs8s+8p5nBNuymIXtXwZ+5r5+XJP3e32nMkuZ/r0HozEA==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-node@3.972.83': + resolution: + { + integrity: sha512-jdso7ejzfRnatxMUZK4S/U6KbaDPCvfIV4XL+IQAPFDBt5rj5Fq595euqlK8Le4lNCMFR9oUpt+1l0aMgaayOQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-process@3.972.71': + resolution: + { + integrity: sha512-lYmXJa4gvq4xN1lrT5NiP5vIYYKcGWAdj8y+8o6dlcateB5eF3Dn8DtmjjHKfMBrTPAMr2pebIiX/UOj8c1/UA==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-sso@3.973.15': + resolution: + { + integrity: sha512-6Jhcf4v0pSFdjk1EW2kvzuEBKD+UZ2uNcHUIglKKLndD20YhvkL2kdmDOV5/j4mYuWWwe/a1FQ1aomU86/Cg5Q==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/credential-provider-web-identity@3.972.77': + resolution: + { + integrity: sha512-uylIQSUWpfLuH2LovxEEfwzJGM/SabLOfLMg6YXu/E8jJEKUdpdILCVCQCdFvHyu/7dLJOHPMfrSwduxO56NkQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/eventstream-handler-node@3.972.34': + resolution: + { + integrity: sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/middleware-eventstream@3.972.29': + resolution: + { + integrity: sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/middleware-websocket@3.972.53': + resolution: + { + integrity: sha512-bIrDaMENQmYRBHntOiOheqkiw5+fhKW4Lqb+mS1uqF0VwvdWI22fW2HFgWrng66CmYd+4k8ePlpj38sEfTuMLQ==, + } + engines: { node: '>= 14.0.0' } + + '@aws-sdk/nested-clients@3.997.45': + resolution: + { + integrity: sha512-mooq9Q+jLa18VoM7HouczmslZU60iiB0aKc/Ztnq/luIL1ud0z4DnYprLR/ZO1gp331S9tJctM1HZr7u6YKBXQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/signature-v4-multi-region@3.996.46': + resolution: + { + integrity: sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/token-providers@3.1048.0': + resolution: + { + integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/token-providers@3.1129.0': + resolution: + { + integrity: sha512-Sbl3rpzQdsG4ZK2zh0JWUYyZPKKorJlVOddA2T0DVbKJFrsW8J6wgnslxxUH04+WaBMr4A1HzJZvZX0xUvkniA==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/types@3.974.5': + resolution: + { + integrity: sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/util-locate-window@3.965.10': + resolution: + { + integrity: sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==, + } + engines: { node: '>=20.0.0' } + + '@aws-sdk/xml-builder@3.972.40': + resolution: + { + integrity: sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==, + } + engines: { node: '>=20.0.0' } + + '@aws/lambda-invoke-store@0.3.0': + resolution: + { + integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==, + } + engines: { node: '>=18.0.0' } + + '@babel/code-frame@7.28.6': + resolution: + { + integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==, + } + engines: { node: '>=6.9.0' } + + '@babel/code-frame@7.29.7': + resolution: + { + integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==, + } + engines: { node: '>=6.9.0' } + + '@babel/compat-data@7.29.7': + resolution: + { + integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==, + } + engines: { node: '>=6.9.0' } + + '@babel/core@7.29.7': + resolution: + { + integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==, + } + engines: { node: '>=6.9.0' } + + '@babel/generator@7.28.6': + resolution: + { + integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==, + } + engines: { node: '>=6.9.0' } + + '@babel/generator@7.29.8': + resolution: + { + integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-compilation-targets@7.29.7': + resolution: + { + integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-globals@7.29.7': + resolution: + { + integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-module-imports@7.29.7': + resolution: + { + integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-module-transforms@7.29.7': + resolution: + { + integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/helper-plugin-utils@7.28.6': + resolution: + { + integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-string-parser@7.27.1': + resolution: + { + integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-string-parser@7.29.7': + resolution: + { + integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-identifier@7.28.5': + resolution: + { + integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-identifier@7.29.7': + resolution: + { + integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-option@7.29.7': + resolution: + { + integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helpers@7.29.7': + resolution: + { + integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==, + } + engines: { node: '>=6.9.0' } + + '@babel/parser@7.28.6': + resolution: + { + integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + '@babel/parser@7.29.8': + resolution: + { + integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: + { + integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: + { + integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: + { + integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: + { + integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-import-attributes@7.28.6': + resolution: + { + integrity: sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: + { + integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: + { + integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: + { + integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: + { + integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: + { + integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: + { + integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: + { + integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: + { + integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: + { + integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: + { + integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: + { + integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: + { + integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==, + } + engines: { node: '>=6.9.0' } + peerDependencies: + '@babel/core': ^7.29.7 + + '@babel/runtime@7.29.7': + resolution: + { + integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==, + } + engines: { node: '>=6.9.0' } + + '@babel/template@7.29.7': + resolution: + { + integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==, + } + engines: { node: '>=6.9.0' } + + '@babel/traverse@7.29.8': + resolution: + { + integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==, + } + engines: { node: '>=6.9.0' } + + '@babel/types@7.28.6': + resolution: + { + integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==, + } + engines: { node: '>=6.9.0' } + + '@babel/types@7.29.8': + resolution: + { + integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==, + } + engines: { node: '>=6.9.0' } + + '@bcoe/v8-coverage@0.2.3': + resolution: + { + integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==, + } + + '@borewit/text-codec@0.2.2': + resolution: + { + integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==, + } + + '@braidai/lang@1.1.2': + resolution: + { + integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==, + } + + '@cfworker/json-schema@4.1.1': + resolution: + { + integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==, + } + + '@colors/colors@1.5.0': + resolution: + { + integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==, + } + engines: { node: '>=0.1.90' } + + '@cspotcode/source-map-consumer@0.8.0': + resolution: + { + integrity: sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==, + } + engines: { node: '>= 12' } + + '@cspotcode/source-map-support@0.7.0': + resolution: + { + integrity: sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==, + } + engines: { node: '>=12' } + + '@earendil-works/pi-agent-core@0.80.10': + resolution: + { + integrity: sha512-nwnOR3SuLYGRFfyQm8ri4Nj5VGVAvAM9GuqQd3u7BUQj0d6hmD2F8w7OHAAjThE3CuySIdM+v8E22QJG6/RfCg==, + } + engines: { node: '>=22.19.0' } + + '@earendil-works/pi-ai@0.80.10': + resolution: + { + integrity: sha512-Moe/H8c87yacDGK9dPbWphZNjVsrb3nTrIHycOQJAkFEnY9PYxOOd74+ny44kATfPU9Dm7aTHefar3pZF+UKUA==, + } + engines: { node: '>=22.19.0' } + hasBin: true + + '@earendil-works/pi-coding-agent@0.80.10': + resolution: + { + integrity: sha512-aL4apbupCHiVLSXASXvRzH4Q2vmtfrDa+0s909CJuVu/GgGylbDzr7oyF1mPmip5E+VxYYxKWmph4hV04wUcQg==, + } + engines: { node: '>=22.19.0' } + hasBin: true + + '@earendil-works/pi-tui@0.80.10': + resolution: + { + integrity: sha512-c2JO29PbhKPEQ6fgHQKAl0WhwuFqzWfzspMmP+8B5tpDuP+0mvarRbKKg8gq4b+pQx/QX+6aVS4ko7deoyjQjg==, + } + engines: { node: '>=22.19.0' } + + '@esbuild/aix-ppc64@0.28.2': + resolution: + { + integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: + { + integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: + { + integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: + { + integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: + { + integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: + { + integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: + { + integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: + { + integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: + { + integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: + { + integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==, + } + engines: { node: '>=18' } + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: + { + integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: + { + integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==, + } + engines: { node: '>=18' } + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: + { + integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==, + } + engines: { node: '>=18' } + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: + { + integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==, + } + engines: { node: '>=18' } + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: + { + integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==, + } + engines: { node: '>=18' } + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: + { + integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==, + } + engines: { node: '>=18' } + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: + { + integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: + { + integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: + { + integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: + { + integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: + { + integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: + { + integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: + { + integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: + { + integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==, + } + engines: { node: '>=18' } + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: + { + integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==, + } + engines: { node: '>=18' } + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: + { + integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==, + } + engines: { node: '>=18' } + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.4.0': + resolution: + { + integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.9.0': + resolution: + { + integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.1': + resolution: + { + integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint-community/regexpp@4.12.2': + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint/config-array@0.21.1': + resolution: + { + integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/config-helpers@0.4.2': + resolution: + { + integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/core@0.17.0': + resolution: + { + integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/eslintrc@3.3.3': + resolution: + { + integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/js@9.39.1': + resolution: + { + integrity: sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/object-schema@2.1.7': + resolution: + { + integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@eslint/plugin-kit@0.4.1': + resolution: + { + integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@faker-js/faker@10.6.0': + resolution: + { + integrity: sha512-3RQHgEtvL1Frl/d1cSreo7qhJ3Gk1OdNUai/CtZ8G+wYeRQnJih3s9xJ9/kgYekPQRdwgh0HXRPqMlzWGwivIQ==, + } + engines: { node: ^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0, npm: '>=10' } + + '@gerrit0/mini-shiki@3.23.0': + resolution: + { + integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==, + } + + '@google/genai@1.52.0': + resolution: + { + integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==, + } + engines: { node: '>=20.0.0' } + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@google/genai@2.23.0': + resolution: + { + integrity: sha512-NFMzktoZ7jJg9AA3vUbWZocnkQP6DSGzL21ORwdVcBWkHli3k7v6B9zy07M4bTiKDAyIWfUh2J/oLYxCu0hNpQ==, + } + engines: { node: '>=20.0.0' } + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@grpc/grpc-js@1.14.5': + resolution: + { + integrity: sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==, + } + engines: { node: '>=12.10.0' } + + '@grpc/proto-loader@0.8.1': + resolution: + { + integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==, + } + engines: { node: '>=6' } + hasBin: true + + '@hono/node-server@2.0.10': + resolution: + { + integrity: sha512-ZcnNVhKTmyDJeg0UlnZjvM73JBsTAuhrH/J4fjwGOw59PwOW51r4J+p6CsKZWXdKSme4MFqU62CZMOsdDrU4CA==, + } + engines: { node: '>=20' } + peerDependencies: + hono: 4.13.5 + + '@humanfs/core@0.19.2': + resolution: + { + integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==, + } + engines: { node: '>=18.18.0' } + + '@humanfs/node@0.16.8': + resolution: + { + integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==, + } + engines: { node: '>=18.18.0' } + + '@humanfs/types@0.15.0': + resolution: + { + integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==, + } + engines: { node: '>=18.18.0' } + + '@humanwhocodes/module-importer@1.0.1': + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: '>=12.22' } + + '@humanwhocodes/retry@0.4.3': + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: '>=18.18' } + + '@inquirer/ansi@2.0.8': + resolution: + { + integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==, + } + engines: { node: '>=23.5.0 || ^22.13.0 || ^20.17.0' } + + '@inquirer/confirm@6.3.2': + resolution: + { + integrity: sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==, + } + engines: { node: '>=23.5.0 || ^22.13.0 || ^20.17.0' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@12.0.3': + resolution: + { + integrity: sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==, + } + engines: { node: '>=23.5.0 || ^22.13.0 || ^20.17.0' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@2.0.9': + resolution: + { + integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==, + } + engines: { node: '>=23.5.0 || ^22.13.0 || ^20.17.0' } + + '@inquirer/type@4.1.1': + resolution: + { + integrity: sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==, + } + engines: { node: '>=23.5.0 || ^22.13.0 || ^20.17.0' } + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@isaacs/cliui@8.0.2': + resolution: + { + integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==, + } + engines: { node: '>=12' } + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: + { + integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==, + } + engines: { node: '>=8' } + + '@istanbuljs/schema@0.1.3': + resolution: + { + integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==, + } + engines: { node: '>=8' } + + '@jest/console@29.7.0': + resolution: + { + integrity: sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/console@30.5.2': + resolution: + { + integrity: sha512-e8sQC4pCMHPBrPX7Hk8TJr+DM5dkUnImSTNag8SEWFAFAF6xIz4AJvCKapnrdHnZxep4bxw4PhG+ZbimYBMyKw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/core@29.7.0': + resolution: + { + integrity: sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/create-cache-key-function@29.7.0': + resolution: + { + integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/diff-sequences@30.5.0': + resolution: + { + integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/environment@29.7.0': + resolution: + { + integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/environment@30.5.2': + resolution: + { + integrity: sha512-nwFPOUMbmsjNNRCpnJpB9HbSZu4C07wDDds++5j+j1MezzPLxMPDdbdUVjr2o4RULDOValnDd70taPFI1EtxAg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/expect-utils@29.7.0': + resolution: + { + integrity: sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/expect-utils@30.5.2': + resolution: + { + integrity: sha512-d/HOLSrJUlBIl6n3LzOa7cjW3xdePm5H5gQw2lixZ/0h157l9sw3mLblzFqnPcTbhDzhRQdBOX3A3KcG73nm5Q==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/expect@29.7.0': + resolution: + { + integrity: sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/expect@30.5.2': + resolution: + { + integrity: sha512-2E+1Pg6jJxbRjLNLpciC71iri/3qsKJL222/aXJEFwC0VdUSvfS+JZQQLEVjUYYc8t8pQu2MGE3cF2+bNTYRsg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/fake-timers@29.7.0': + resolution: + { + integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/fake-timers@30.5.2': + resolution: + { + integrity: sha512-GKk0h0tKT5SpDPxDhPg3r9mm5s8/YCBzVNMOGdRiQXwUiMsjU10mVPlMaQstH6/cu50vOb8N9oQUMETHuwwxKw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/get-type@30.5.0': + resolution: + { + integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/globals@29.7.0': + resolution: + { + integrity: sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/globals@30.5.2': + resolution: + { + integrity: sha512-62zfS+dL+M5aTNHXCNLyqD+j4oqxTIzDYrZbpqMP8Yn+gvlAGyzC6BSPNb1ZmFOakV1RtFYX/O3FCQXSWmQdgg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/pattern@30.5.0': + resolution: + { + integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/reporters@29.7.0': + resolution: + { + integrity: sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/reporters@30.5.2': + resolution: + { + integrity: sha512-ev6E9iG73twQcfAfoTnviuYkr3yOrYbzAtMFCimv2fxHXPIp9PBt2u1wNMt0aYWgl8FPwIcS8oqRJg6alnht4Q==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@29.6.3': + resolution: + { + integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/schemas@30.5.0': + resolution: + { + integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/snapshot-utils@30.5.1': + resolution: + { + integrity: sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/source-map@29.6.3': + resolution: + { + integrity: sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/test-result@29.7.0': + resolution: + { + integrity: sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/test-result@30.5.2': + resolution: + { + integrity: sha512-eO6YU5rsLfs60ne694e0IAmRgeBnoA8mu0nlxXDbl/1j5km0o2vO9/VbbUIKIH+WRKKEI+ELgwraRGn2Lhep8g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/test-sequencer@29.7.0': + resolution: + { + integrity: sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/transform@29.7.0': + resolution: + { + integrity: sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/transform@30.5.2': + resolution: + { + integrity: sha512-LBGwmaE886C41jDZ65eqR1YbXM7642MK3ZhTowZd5+xDlrDLuf4ePuDdDmVSvcr8NTVJ3MqxLkMbUCzuigUaoQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jest/types@29.6.3': + resolution: + { + integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + '@jest/types@30.5.1': + resolution: + { + integrity: sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + '@jitl/quickjs-ffi-types@0.32.0': + resolution: + { + integrity: sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==, + } + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + resolution: + { + integrity: sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==, + } + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + resolution: + { + integrity: sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==, + } + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + resolution: + { + integrity: sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==, + } + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + resolution: + { + integrity: sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==, + } + + '@jridgewell/gen-mapping@0.3.13': + resolution: + { + integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, + } + + '@jridgewell/remapping@2.3.5': + resolution: + { + integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, + } + + '@jridgewell/resolve-uri@3.1.2': + resolution: + { + integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, + } + engines: { node: '>=6.0.0' } + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: + { + integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==, + } + + '@jridgewell/trace-mapping@0.3.31': + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, + } + + '@js-sdsl/ordered-map@4.4.2': + resolution: + { + integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==, + } + + '@langchain/core@0.3.80': + resolution: + { + integrity: sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==, + } + engines: { node: '>=18' } + + '@langchain/langgraph-checkpoint@0.0.18': + resolution: + { + integrity: sha512-IS7zJj36VgY+4pf8ZjsVuUWef7oTwt1y9ylvwu0aLuOn1d0fg05Om9DLm3v2GZ2Df6bhLV1kfWAM0IAl9O5rQQ==, + } + engines: { node: '>=18' } + peerDependencies: + '@langchain/core': '>=0.2.31 <0.4.0' + + '@langchain/langgraph-sdk@0.0.112': + resolution: + { + integrity: sha512-/9W5HSWCqYgwma6EoOspL4BGYxGxeJP6lIquPSF4FA0JlKopaUv58ucZC3vAgdJyCgg6sorCIV/qg7SGpEcCLw==, + } + peerDependencies: + '@langchain/core': '>=0.2.31 <0.4.0' + react: ^18 || ^19 + react-dom: ^18 || ^19 + peerDependenciesMeta: + '@langchain/core': + optional: true + react: + optional: true + react-dom: + optional: true + + '@langchain/langgraph@0.3.12': + resolution: + { + integrity: sha512-4jKvfmxxgQyKnCvXdFbcKt6MdfaJoQ2WWqBR16o2E6D2RxqHvnLMMClZh4FSd6WYw39z5LGWvzRapFbRMqxu1A==, + } + engines: { node: '>=18' } + peerDependencies: + '@langchain/core': '>=0.3.58 < 0.4.0' + zod-to-json-schema: ^3.x + peerDependenciesMeta: + zod-to-json-schema: + optional: true + + '@langchain/openai@0.6.17': + resolution: + { + integrity: sha512-JVSzD+FL5v/2UQxKd+ikB1h4PQOtn0VlK8nqW2kPp0fshItCv4utrjBKXC/rubBnSXoRTyonBINe8QRZ6OojVQ==, + } + engines: { node: '>=18' } + peerDependencies: + '@langchain/core': '>=0.3.68 <0.4.0' + + '@langchain/textsplitters@0.1.0': + resolution: + { + integrity: sha512-djI4uw9rlkAb5iMhtLED+xJebDdAG935AdP4eRTB02R7OB/act55Bj9wsskhZsvuyQRpO4O1wQOp85s6T6GWmw==, + } + engines: { node: '>=18' } + peerDependencies: + '@langchain/core': '>=0.2.21 <0.4.0' + + '@loaderkit/resolve@1.0.6': + resolution: + { + integrity: sha512-G8FdIoF5CypfwmD9rl8BXod5HDn8JqB0CCNBXDTaRZ+yRYhARrrSToX1zg1zy9jX3zLqigsELwhT4gNtkdQAUg==, + } + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: + { + integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: + { + integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==, + } + engines: { node: '>= 10' } + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: + { + integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: + { + integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: + { + integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: + { + integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==, + } + engines: { node: '>= 10' } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: + { + integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: + { + integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: + { + integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==, + } + engines: { node: '>= 10' } + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: + { + integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==, + } + engines: { node: '>= 10' } + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: + { + integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==, + } + engines: { node: '>= 10' } + + '@mistralai/mistralai@2.2.6': + resolution: + { + integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==, + } + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@mixmark-io/domino@2.2.0': + resolution: + { + integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==, + } + + '@modelcontextprotocol/sdk@1.30.0': + resolution: + { + integrity: sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==, + } + engines: { node: '>=18' } + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mongodb-js/saslprep@1.5.4': + resolution: + { + integrity: sha512-05UC0jQsjKAOuXQ0H9Ud9vUTJpZIg+n/FinpR30tI5I8pY2inTfPOZ5OF/cg3Ce/N9MoD1xhRCeOsJtuTbFYlw==, + } + + '@mongodb-js/zstd@7.0.0': + resolution: + { + integrity: sha512-mQ2s0pYYiav+tzCDR05Zptem8Ey2v8s11lri5RKGhTtL4COVCvVCk5vtyRYNT+9L8qSfyOqqefF9UtnW8mC5jA==, + } + engines: { node: '>= 20.19.0' } + + '@mswjs/interceptors@0.41.9': + resolution: + { + integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==, + } + engines: { node: '>=18' } + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: + { + integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==, + } + engines: { node: ^22.20 || ^24.12 || >=25 } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@nodable/entities@3.0.0': + resolution: + { + integrity: sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==, + } + + '@nodelib/fs.scandir@2.1.5': + resolution: + { + integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.stat@2.0.5': + resolution: + { + integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==, + } + engines: { node: '>= 8' } + + '@nodelib/fs.walk@1.2.8': + resolution: + { + integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==, + } + engines: { node: '>= 8' } + + '@open-draft/deferred-promise@2.2.0': + resolution: + { + integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==, + } + + '@open-draft/deferred-promise@3.0.0': + resolution: + { + integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==, + } + + '@open-draft/logger@0.3.0': + resolution: + { + integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==, + } + + '@open-draft/until@2.1.0': + resolution: + { + integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==, + } + + '@openai/agents-core@0.12.1': + resolution: + { + integrity: sha512-j3Tm0SYfgU3QIHqoCbMXS0sUghS0qdYrGc6up99o5lgxwCIuOCZixNvj+G/v5VX2EwhNN1FCVofIfqJWeb1LUg==, + } + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@openai/agents-openai@0.12.1': + resolution: + { + integrity: sha512-2hIPEX7fpBpyZv8eC5slIvpkFvummcQQHVs+3xF6eHLSU0pk+Buo2u1B6DusnjkI29EKVZjVtJRlqDfnb0LBuA==, + } + peerDependencies: + zod: ^4.0.0 + + '@openai/agents-realtime@0.12.1': + resolution: + { + integrity: sha512-LdKGct2pOKiO5b6eY8AULLvX15rVBGvngmXpB5VBZXOmySSaVMvyKM9+hBk8NeyxDffWX1GjnZN7hZeqYUpBeQ==, + } + peerDependencies: + zod: ^4.0.0 + + '@openai/agents@0.12.1': + resolution: + { + integrity: sha512-FneYZ3wFy/YOa+ZwxbiRVm7FgfRmX1oGWWJ2QiJQGMqwrA8OsVOhoi9KfpeTpSQAg5wa22+qljPtVx7KnVsQwQ==, + } + peerDependencies: + zod: ^4.0.0 + + '@opentelemetry/api-logs@0.222.0': + resolution: + { + integrity: sha512-9mb1If+IF6u0ZVXkHQ6ogEae5HwA6ajIVUgpSDQyRASxft6BSXHvBvPooRle3yFN/fKnCdSOnuu0OC3PLcF6+g==, + } + engines: { node: '>=8.0.0' } + + '@opentelemetry/api@1.9.0': + resolution: + { + integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==, + } + engines: { node: '>=8.0.0' } + + '@opentelemetry/api@1.9.1': + resolution: + { + integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==, + } + engines: { node: '>=8.0.0' } + + '@opentelemetry/auto-instrumentations-node@0.80.0': + resolution: + { + integrity: sha512-McPSxdNZqudZnC8WsYti63QyDoj+QOB4EKVp3Ndrq+gq5UOoi2GcDvXjphIxgXUFoJ3jzWoiczhReKW23OKsOw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.222.0': + resolution: + { + integrity: sha512-zNWGmEvyX7CUbS6F6PKi7hf3CF/SffzeBYyWVQAfadZ8hODrvLmqV3IUGVt1SiqyYanRPHc8VuhoO0Qh9OEuug==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.11.0': + resolution: + { + integrity: sha512-Tr79DyWI8itsBdg+jH+opjfrwLzX+erk1/ExkIwhWoAVjVrJIn2y5+cGjTC0Vy8fyNIA/y8wuJPZwr1T3xCZeQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.11.0': + resolution: + { + integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0': + resolution: + { + integrity: sha512-HH4Oi/E8HXBKtcjgcVPJGsrg+lSstdw1m93brRdpVj5CjMpyTkv/O0ZYMXP6QXVEaLjjFivtzUu4Y8+i1UJpjw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.222.0': + resolution: + { + integrity: sha512-GqwLZohJTf/sttYlOpT1oFTJ9ScYaKnk0sOx4Qe/+KmXYVOa+v94cfnkUkFKDuvTODWhCL50Hz3gvsZD6ngQpg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0': + resolution: + { + integrity: sha512-LlCDJuaznfFPzjHoKXjN/r9T5pgCZpmVheZ/EmwMUhMvpxI7RXDmsdLXoYpl5suYQNkwyXAUZhVEXKe3lev8Hg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0': + resolution: + { + integrity: sha512-nPU2gpQWJfQttf+A71aLkKEOQoO6n6QGs5QTtPagi/rb3Lc1I55bqRXoLQEZsWqWimc8eas2Vo168NkTaByxQw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0': + resolution: + { + integrity: sha512-6Ko+0bgNP6V4G/RsmNBSk5lO1B/lo9z6tvLpFY3ykPPevXNzFmOW3C9hEmTt0zKCvHovyrol3TYlg4HVb5gwjg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0': + resolution: + { + integrity: sha512-s1ZPwS8Ig7cpRmL3kCobPaFbemM81dJ03LNPrsuN32h3mCDvNF2NQ1pRHruOju0P0o1TZ24LkZA685rtQal1KA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.222.0': + resolution: + { + integrity: sha512-I7nalnnBZrhlTgjlqsVeEjJGcQCFK5kKAYVk7XTz/cbubvj5fvjLOal/Many0taHF2GIkOWmegsbQ8vbQJjhFg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0': + resolution: + { + integrity: sha512-uisilePEdOMa3hIfXW82XXU6FofF8gw3iv+PoboIMWEUBC7XbJB3XnIHSdrSDsqCw3njR/If08qD0yzOY1U9Sg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.222.0': + resolution: + { + integrity: sha512-RCnPWcHppwiquQ+cV3nWvNwdf0MG1w26e5jewW2T83nTZOlXgcg88sY9ulCVgagGHcq1mj0L0GP6YHgzk2v8oA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0': + resolution: + { + integrity: sha512-imLrLzNhnABJt3tUyT63h6MqfZrG3Gdv3TE7+OUQTvwD088RfCUx3rF01lcgfu2KXBFk13vwi0yKZLvFEalkIA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.11.0': + resolution: + { + integrity: sha512-mFibA45yXLEETIj+VcTVycFFe4R91iD41d+r3CU0f3DDWJCrICiHxUIjmYBTCZIuywXEeNo++2lncQeWnH7lWQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.69.0': + resolution: + { + integrity: sha512-hoSfNGxqospEFw0S2RH7cY8MF5U1BRKsAfX7PhiiZgGDWxPcmfAKYdXilmn0DqPBGRFYzq+HbtSp+R+2sy4mXA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.74.0': + resolution: + { + integrity: sha512-N7L+NIyndEUWC0EjtN7OmNBF0J5beXQ81+46qt1NCA2YDC95os9TIh2iWfKL+U3wINkJeStMWBEQgFZhEvj1qw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.77.0': + resolution: + { + integrity: sha512-igri8DF7ZWlYtQjT1u2A3v6Grb59VSsYiheGrLpnI7rlEAxmFeWHPwiwzzko39lM0LO7Ntf3VJPRBA1ZNGwF1w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.67.0': + resolution: + { + integrity: sha512-a/ucku1yx4qW2zpYpepTM/rZlv92DG/C+k/Kxn/nqhh5HOoe3pHFbXUXunvy2EtHW/7l81PI++AUm+rIxXqlTg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.67.0': + resolution: + { + integrity: sha512-PIHbjmBWfYeYqAH8XDCyoJoXaR6TkEakNw3BX8uDt8b9zjYk3uetjCoP8nubRDb8m30vkDbs09Qg09pFOXKj2w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.65.0': + resolution: + { + integrity: sha512-i7xkOkzzeaYbaK2Gjpxa6g9FJd1nqoH0+KFyRXV0iweH/F2+xZPintRFD5gcSN5/H4mpfslsLbDqRT897CvzEQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.38.0': + resolution: + { + integrity: sha512-sorgrR3FOBy+YEwVutxcm84JTI0luCNIGp0kiL+jSoUrH9W/pYVSpP4isLB3VtQMgNnD4cd0e9qNvvr7RwKLpA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.39.0': + resolution: + { + integrity: sha512-01+jNP1hESK8GYTCuRMoiQAEf9ZI5b3XwfSk7bWDiXLB2/zml5Md/U/xaWyFHQ2kYyU553Qvr8dcyup5Beu7qA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.65.0': + resolution: + { + integrity: sha512-7QXICs4eV/GeihRqPnsR7TnK6ugAK8/B7tWr8UFxRzGTfNG97LEfByevhNfMEE8pFFLHUhiCE5metED2lO8nhw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.70.0': + resolution: + { + integrity: sha512-OHdj/2apRZNhMKkd6w4OV4SnX/aS2LWMLyHwGGNYhWratgh88o+cVJS9FvqC+HCYWSDWTEWEfl/8d7BdEI7c+g==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.41.0': + resolution: + { + integrity: sha512-rk1RcxvOeb5j7Gi6P/PB6M9MRRYQzp4t9ZYN/i6jVM+m0j2zCUYGH4Xo6omNh0FhTDhhytDPG6o95p82eXFL2w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.65.0': + resolution: + { + integrity: sha512-iKkn/8m0oerf00qCxz9/xeua+EieX6tlSmLhGiVLgLsOspmV1sb33ee8RPiWnJiandGVJgKA0GTGUZXwVKgPnQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.70.0': + resolution: + { + integrity: sha512-zyBTEG0Dxm6rYf0IaoIqt1ZRAuiq4AUPDhAi0vl/hBbYweU56CleS5zH8n+GQ3+EsTBW6KYKULHvXk21+UJo9w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.222.0': + resolution: + { + integrity: sha512-5gUFhJF5X8ss60apt2p7czvIeIzTHZQSq1QowiKVS4gBfP5nHZNhBYoOtq1j14A8IXs/qL8Hn/sfni4kaPVLRA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.68.0': + resolution: + { + integrity: sha512-wJShEI4aKajQT5Sg/sJ6jV3xF6SqJKfXHTLDdrmWMv0h1sDXp4mpSTD1XUJfO5H0lZFUtEJapxtWl126zAmf9w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-host-metrics@0.5.0': + resolution: + { + integrity: sha512-rAw5lmY2u3bwUFohRtR4ZYNDnhMTiSeQqq8BB4Bo+aJQ+7vIOOwW/3d4awLPMDQ69VeA5jsh+ICL5oJinrWIwA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.222.0': + resolution: + { + integrity: sha512-HhMHW32LyGzIJEmUElxaML4oUe6JiCq0Hvjt/Awxb60SlMsSFetFLFjed45g/0wrPr1n7z+xW+x1B/584WBAkg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.70.0': + resolution: + { + integrity: sha512-AUyn8K5vlSGkIF2AXPlUsa5d5ZXRidHMXlAHNAykvgW3u8juDZqGTgsOKJMYnLfllawygawLrqK6Fr2lnPcBUQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.31.0': + resolution: + { + integrity: sha512-9/EBOb1aW5ai4+U7mu2ER5fhJ7OYZNK/ghRSETfCf92xCeoQkZcS8LEF8e05w6OlrgZCwq7x+vLuUxXtkRPv3w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.66.0': + resolution: + { + integrity: sha512-tPI1T0ur+P2qvsOWcadv9ViWtJ8g0XsKOAMPiR5rasZBZAC6nEI4u4BAnAhaZtdZkuMJrGa4uZur5xTn1bv7MQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.70.0': + resolution: + { + integrity: sha512-+CFyL7V0auN0FzKvGUwOIOGT/8VTC3BM8cd8X0UoHRmPKEdYfwNWuHIkA6zknZPDMXN9WLfZCxdZ0Ln/YqLx4w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.66.0': + resolution: + { + integrity: sha512-kYJQFqkR2eEwGzqTRw2Nc/NiQyrQRU3rLHmsEvdnlf40YRfCBadbDu3I8C5GWoVn1XyEKFTvmn85UoslRpbUjA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.65.0': + resolution: + { + integrity: sha512-QLGrx33THENogCdkBi1DJxPwvWe4QInoKUbx7u15zb37bxrKww68ZrqbMF4luq9YZbGQUFqEsnSprHV8kU93QA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.75.0': + resolution: + { + integrity: sha512-Bf9Az6zdOsnRFUew1CToTKpfhcFkAJ4SgmnGpxID/vuzVHI31oZhAwuKBTHny/Eq7ydXfncDbET8jV6d3L8REw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.68.0': + resolution: + { + integrity: sha512-GrDPpy9XHwWJUpKb4NjH8bdRU9EvtS37D102LhMOnjNOp6ouvtVBmFcPLMsJKsZcFOG4YykSERnau4l5a+sy2w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.68.0': + resolution: + { + integrity: sha512-xNvUL0nwk/t0gGPb1GSQJWKiR3gj4Cd3vk4palrttLy/FYbnRBOOBRCobik3grAMzjsynEgyYdzEziXx/CvpyQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.68.0': + resolution: + { + integrity: sha512-N6TCMiWmisdG9DzSuMHxZ+N5qD3meoCy70mVE6fneZj6M24EXj86e56BzLFQ8Pkd6BgaPBJcdMTT/GBfPuW15w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.68.0': + resolution: + { + integrity: sha512-BCsqKPLWVDgrTDyVScRwENWg8uNH7LA0QBy+ojuPQx9C9F85yOhNkale5MdmQG0su7L6BLLTNPByZOZeKp3c5A==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.66.0': + resolution: + { + integrity: sha512-OxnzC0696+Ne7zHisHo95RZkYT4e2C4OoB+q0dqQyZ0/9klLQ9CyC/Yu+OzYrQzPjdzRQVVPNDhoW+5+cXk3XQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.20.0': + resolution: + { + integrity: sha512-vl8ReH9dq9goRWbajF0xQt/mnCZDl2KCcmOvgC1sSKCRA8DnqSMq7Z9ZVKPA9PnaV4tGInb39qtGdGIORUkEqQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.47.0': + resolution: + { + integrity: sha512-3DDgcsl2CFX4829TxZiNYD29pFN9xyT71HUBN475CuFQ94eq+1jyMZPshO+K7qJltrJY2Df3MWV2ANrlw8H8fQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.74.0': + resolution: + { + integrity: sha512-8i59Z7M0FjEwp2hF1eRqEr+NZtiwjvPYGl2xsGWEfL/hF6ffmPERWbySyK1EXa7gGyHCArwNkHLShsMfFywqpQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.68.0': + resolution: + { + integrity: sha512-U0ggAM9eqDe7jsUnZeFMv6QIZwahNfyPZQAlUV9ZQ3aAYpaq+Ag9Okec75Fgzwh72+e4gewfT99HObkDg6Y/bA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.70.0': + resolution: + { + integrity: sha512-jXHduXt6GQWluGtqBPibAvpw27seRjA6SMyLp0Oc41AzSQz1JFZq1CNw6gvdOrRasH4AJPpRykPxw02IjHYr0A==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.67.0': + resolution: + { + integrity: sha512-T4sjjxX0CaeYNEMUNEDbCL9nGPIRkEmAzj7b1HwI0YJgZV459aStVsgJRTKmuaeIoJSihTqH2yUmcB+gdR5Qxg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.66.0': + resolution: + { + integrity: sha512-18GWiTNuIMDFxepxYV0PVikAAMmcqLZsMziJXi2PyuP3r9msPjMJnU7RZ+DLS1N8amwWit3EZYmFI4dNE4d9uQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.35.0': + resolution: + { + integrity: sha512-/cOW4Qlv4nnr4QXDCkv0TEtOBa9R9lXHu//JuD5+nRPYR74g9iLmf5tadcYvn32oz2R8vQnV8FTnMLzjfAGMCQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.69.0': + resolution: + { + integrity: sha512-VlS6GFeWd32d09WziTZR9cfLPNJ1nV747suBCgUivRVzIwrg1UG+DBCmMX/kvrMmGJcFRAbf6I6s6nLsR6+Jag==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.41.0': + resolution: + { + integrity: sha512-dtrHwEs40nzD/fR9jkSNbSiHqCIHf2sXcMM8nY29Ekc25pw0EADRBqN4Ya2prrCSNGVtIx6N9o3fTS7fsACysA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.32.0': + resolution: + { + integrity: sha512-Ms8oPkrCHa6hJOYodcFC8QOt1AFlns3KD1CqXBEihXyTFeLXDO84tIXYyKlXNHr8jMrl1Gm2velDvsSDXAuh9w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.66.0': + resolution: + { + integrity: sha512-wwBZnlq9/2qiBpIoLB5yC88Ws0zoOLFde6DuIbEYL+SmC+bgYBITYV59AIlaDGpvos/n7AM+k8EFw+y/ZQVudw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.222.0': + resolution: + { + integrity: sha512-fhbRDuAgzPKpq1k5+hX3uS+3QAYNca0on0Ngot/R8dDsQtuFaADeZnG2uSgxABuvcI7S2phAaRhbct9jieeKZw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.222.0': + resolution: + { + integrity: sha512-YbywG3veEm2Fb6TbdxRkuquWob6eVWXuA8/Ba1tXz9jHfUqpdE3keilOHEtPboC4CvS1bjeeVfNkWGOOrLj+lw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0': + resolution: + { + integrity: sha512-Kw5WqDBYYpCgY+edHWZS/Jp3bdguqNkCyEWOsqFhf8RmtcEuWYzPFA9i+L6UsNoJXTs8ngeQB6dwELH1czNYjA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.222.0': + resolution: + { + integrity: sha512-/F3BZ89+CJQnZkMh2tCrtcdB+XT2Dxhj4FFE+WPQ//413hmFL0/RfEX6vgOIWGhiSzrkHWTK3+6SiT7K5/g/jQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-aws-xray@2.2.0': + resolution: + { + integrity: sha512-Yjvt2EjL+tfpkVOdKbhTPgpM4SIAez9nG6Q/QjQ3yfcJcjIWp59ph70SLfvmkSL6++3DCnuBG3iWcB18PwWavQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-b3@2.11.0': + resolution: + { + integrity: sha512-ldyRZXx6qsqu9Spqd7rVxo6SNAUJqP8Ir3vwIi4N8xyZLXNl/2XeQmIHvFneiVOqNwWkU+JGFg2O9/T1fefU0w==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.11.0': + resolution: + { + integrity: sha512-RUOqgoIqXOPQTOhpBrs07Y8sBlxVoLY5Fka6ueZ0prW+k6fWQsba/nHDY4gm+gmfuwtRqbX5Y9YKhDvktjbIqQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.3': + resolution: + { + integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + + '@opentelemetry/resource-detector-alibaba-cloud@0.37.0': + resolution: + { + integrity: sha512-tEbi6ZPrPZG79oZdQlzw5xR7sirtYy+LBbt/S/JxDUZnmSWEYmo7l2jp985XU4UhsfMDpcfdOY+Gnt2bPMtvyQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.22.0': + resolution: + { + integrity: sha512-3kM+xfsSHs0hShUAx9IFhX5jniaXxJL3CTjbCMNCFS+EY7bswWbukV3au0jfqEgzeNxGxHEy8RLA1TOhAVLpTQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.30.0': + resolution: + { + integrity: sha512-fwGDPVGJ6PyRMc5CPdHBfASX5prg3mLhZA0rs+n8ZJK4DjpJw9CvB1FujWl8l0QAsTmvrYBm9Deal3fvfqZBkQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.13': + resolution: + { + integrity: sha512-44AKD/FOpC4amfsZkKvtymUnThWLBPTrZavv5tpRPVE3REEtFCbQV0rMT5lTRXmDkg8rk8M/2RX49NZdTSnpjw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.57.0': + resolution: + { + integrity: sha512-wp+hk7jD7mxA6swAqa5wQ3jws2egh1DCPZ9FfEgntmFw/mc8MaGZjwuttOoMeRjIlcBCCaNUHuN8B+MZaujAXQ==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.11.0': + resolution: + { + integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.222.0': + resolution: + { + integrity: sha512-+19YHODIjaUCArxleaJtuufFZVpz/xvvK+VllQqE+W8hHolxdoRwHfK/s667zezwh1hkx6FFF+oYzetYgqK+Bg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.11.0': + resolution: + { + integrity: sha512-7GXXcObyHyDUUSG+L+kJoquty01bzm7ivE7+SSgXXJcHuPzGviptxwARmI2c+bnnxjexGQbJnyNlN8HxBP/Y7A==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.222.0': + resolution: + { + integrity: sha512-t8tlFN/ezYV7iI4k6u3t2jFbIReX99Z0iVMVSYLJscE4McxIW9YGHhdOldeY1elHeoq8mjEO4B9s4Fk7+zJbsg==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.11.0': + resolution: + { + integrity: sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.11.0': + resolution: + { + integrity: sha512-CuvCMJmZxswhNLlM2LfuLOW3h3fZujA4hsG4B+Sz4dX2zvaXO8Ng74cnDHWD64gLszTlhiG3c0iNUjj4g+0/sA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/sdk-trace@2.11.0': + resolution: + { + integrity: sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: + { + integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==, + } + engines: { node: '>=14' } + + '@opentelemetry/sql-common@0.42.0': + resolution: + { + integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==, + } + engines: { node: ^18.19.0 || >=20.6.0 } + peerDependencies: + '@opentelemetry/api': ^1.1.0 + + '@parcel/watcher-android-arm64@2.6.0': + resolution: + { + integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: + { + integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: + { + integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==, + } + engines: { node: '>= 10.0.0' } + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: + { + integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==, + } + engines: { node: '>= 10.0.0' } + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: + { + integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: + { + integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: + { + integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: + { + integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: + { + integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==, + } + engines: { node: '>= 10.0.0' } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: + { + integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==, + } + engines: { node: '>= 10.0.0' } + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: + { + integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==, + } + engines: { node: '>= 10.0.0' } + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: + { + integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==, + } + engines: { node: '>= 10.0.0' } + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: + { + integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==, + } + engines: { node: '>= 10.0.0' } + + '@pkgjs/parseargs@0.11.0': + resolution: + { + integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==, + } + engines: { node: '>=14' } + + '@pkgr/core@0.3.6': + resolution: + { + integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + + '@protobufjs/aspromise@1.1.2': + resolution: + { + integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==, + } + + '@protobufjs/base64@1.1.2': + resolution: + { + integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==, + } + + '@protobufjs/codegen@2.0.5': + resolution: + { + integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==, + } + + '@protobufjs/eventemitter@1.1.1': + resolution: + { + integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==, + } + + '@protobufjs/fetch@1.1.1': + resolution: + { + integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==, + } + + '@protobufjs/float@1.0.2': + resolution: + { + integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==, + } + + '@protobufjs/path@1.1.2': + resolution: + { + integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==, + } + + '@protobufjs/pool@1.1.0': + resolution: + { + integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==, + } + + '@protobufjs/utf8@1.1.2': + resolution: + { + integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==, + } + + '@rollup/rollup-android-arm-eabi@4.63.4': + resolution: + { + integrity: sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==, + } + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.4': + resolution: + { + integrity: sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==, + } + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.4': + resolution: + { + integrity: sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==, + } + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.4': + resolution: + { + integrity: sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==, + } + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.4': + resolution: + { + integrity: sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==, + } + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.4': + resolution: + { + integrity: sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==, + } + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + resolution: + { + integrity: sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==, + } + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + resolution: + { + integrity: sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==, + } + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + resolution: + { + integrity: sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.4': + resolution: + { + integrity: sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + resolution: + { + integrity: sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==, + } + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.4': + resolution: + { + integrity: sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==, + } + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + resolution: + { + integrity: sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==, + } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + resolution: + { + integrity: sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==, + } + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + resolution: + { + integrity: sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==, + } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + resolution: + { + integrity: sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==, + } + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + resolution: + { + integrity: sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==, + } + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.4': + resolution: + { + integrity: sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.4': + resolution: + { + integrity: sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.4': + resolution: + { + integrity: sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==, + } + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.4': + resolution: + { + integrity: sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==, + } + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + resolution: + { + integrity: sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==, + } + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + resolution: + { + integrity: sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==, + } + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.4': + resolution: + { + integrity: sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==, + } + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.4': + resolution: + { + integrity: sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==, + } + cpu: [x64] + os: [win32] + + '@shikijs/engine-oniguruma@3.23.0': + resolution: + { + integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==, + } + + '@shikijs/langs@3.23.0': + resolution: + { + integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==, + } + + '@shikijs/themes@3.23.0': + resolution: + { + integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==, + } + + '@shikijs/types@3.23.0': + resolution: + { + integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==, + } + + '@shikijs/vscode-textmate@10.0.2': + resolution: + { + integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==, + } + + '@silvia-odwyer/photon-node@0.3.4': + resolution: + { + integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==, + } + + '@sinclair/typebox@0.27.8': + resolution: + { + integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==, + } + + '@sinclair/typebox@0.34.52': + resolution: + { + integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==, + } + + '@sindresorhus/is@4.6.0': + resolution: + { + integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==, + } + engines: { node: '>=10' } + + '@sinonjs/commons@3.0.0': + resolution: + { + integrity: sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==, + } + + '@sinonjs/commons@3.0.1': + resolution: + { + integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==, + } + + '@sinonjs/fake-timers@10.3.0': + resolution: + { + integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==, + } + + '@sinonjs/fake-timers@15.4.0': + resolution: + { + integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==, + } + + '@smithy/core@3.34.1': + resolution: + { + integrity: sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==, + } + engines: { node: '>=18.0.0' } + + '@smithy/credential-provider-imds@4.5.2': + resolution: + { + integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==, + } + engines: { node: '>=18.0.0' } + + '@smithy/fetch-http-handler@5.8.0': + resolution: + { + integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==, + } + engines: { node: '>=18.0.0' } + + '@smithy/is-array-buffer@2.2.0': + resolution: + { + integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==, + } + engines: { node: '>=14.0.0' } + + '@smithy/node-http-handler@4.12.1': + resolution: + { + integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==, + } + engines: { node: '>=18.0.0' } + + '@smithy/node-http-handler@4.7.3': + resolution: + { + integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==, + } + engines: { node: '>=18.0.0' } + + '@smithy/signature-v4@5.7.3': + resolution: + { + integrity: sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==, + } + engines: { node: '>=18.0.0' } + + '@smithy/types@4.18.0': + resolution: + { + integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==, + } + engines: { node: '>=18.0.0' } + + '@smithy/util-buffer-from@2.2.0': + resolution: + { + integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==, + } + engines: { node: '>=14.0.0' } + + '@smithy/util-utf8@2.3.0': + resolution: + { + integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==, + } + engines: { node: '>=14.0.0' } + + '@stablelib/base64@1.0.1': + resolution: + { + integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==, + } + + '@standard-schema/spec@1.1.0': + resolution: + { + integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==, + } + + '@swc/core-darwin-arm64@1.4.16': + resolution: + { + integrity: sha512-UOCcH1GvjRnnM/LWT6VCGpIk0OhHRq6v1U6QXuPt5wVsgXnXQwnf5k3sG5Cm56hQHDvhRPY6HCsHi/p0oek8oQ==, + } + engines: { node: '>=10' } + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.4.16': + resolution: + { + integrity: sha512-t3bgqFoYLWvyVtVL6KkFNCINEoOrIlyggT/kJRgi1y0aXSr0oVgcrQ4ezJpdeahZZ4N+Q6vT3ffM30yIunELNA==, + } + engines: { node: '>=10' } + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.4.16': + resolution: + { + integrity: sha512-DvHuwvEF86YvSd0lwnzVcjOTZ0jcxewIbsN0vc/0fqm9qBdMMjr9ox6VCam1n3yYeRtj4VFgrjeNFksqbUejdQ==, + } + engines: { node: '>=10' } + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.4.16': + resolution: + { + integrity: sha512-9Uu5YlPbyCvbidjKtYEsPpyZlu16roOZ5c2tP1vHfnU9bgf5Tz5q5VovSduNxPHx+ed2iC1b1URODHvDzbbDuQ==, + } + engines: { node: '>=10' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-arm64-musl@1.4.16': + resolution: + { + integrity: sha512-/YZq/qB1CHpeoL0eMzyqK5/tYZn/rzKoCYDviFU4uduSUIJsDJQuQA/skdqUzqbheOXKAd4mnJ1hT04RbJ8FPQ==, + } + engines: { node: '>=10' } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@swc/core-linux-x64-gnu@1.4.16': + resolution: + { + integrity: sha512-UUjaW5VTngZYDcA8yQlrFmqs1tLi1TxbKlnaJwoNhel9zRQ0yG1YEVGrzTvv4YApSuIiDK18t+Ip927bwucuVQ==, + } + engines: { node: '>=10' } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@swc/core-linux-x64-musl@1.4.16': + resolution: + { + integrity: sha512-aFhxPifevDTwEDKPi4eRYWzC0p/WYJeiFkkpNU5Uc7a7M5iMWPAbPFUbHesdlb9Jfqs5c07oyz86u+/HySBNPQ==, + } + engines: { node: '>=10' } + cpu: [x64] + os: [linux] + libc: [musl] + + '@swc/core-win32-arm64-msvc@1.4.16': + resolution: + { + integrity: sha512-bTD43MbhIHL2s5QgCwyleaGwl96Gk/scF2TaVKdUe4QlJCDV/YK9h5oIBAp63ckHtE8GHlH4c8dZNBiAXn4Org==, + } + engines: { node: '>=10' } + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.4.16': + resolution: + { + integrity: sha512-/lmZeAN/qV5XbK2SEvi8e2RkIg8FQNYiSA8y2/Zb4gTUMKVO5JMLH0BSWMiIKMstKDPDSxMWgwJaQHF8UMyPmQ==, + } + engines: { node: '>=10' } + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.4.16': + resolution: + { + integrity: sha512-BPAfFfODWXtUu6SwaTTftDHvcbDyWBSI/oanUeRbQR5vVWkXoQ3cxLTsDluc3H74IqXS5z1Uyoe0vNo2hB1opA==, + } + engines: { node: '>=10' } + cpu: [x64] + os: [win32] + + '@swc/core@1.4.16': + resolution: + { + integrity: sha512-Xaf+UBvW6JNuV131uvSNyMXHn+bh6LyKN4tbv7tOUFQpXyz/t9YWRE04emtlUW9Y0qrm/GKFCbY8n3z6BpZbTA==, + } + engines: { node: '>=10' } + peerDependencies: + '@swc/helpers': ^0.5.0 + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: + { + integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==, + } + + '@swc/jest@0.2.36': + resolution: + { + integrity: sha512-8X80dp81ugxs4a11z1ka43FPhP+/e+mJNXJSxiNYk8gIX/jPBtY4gQTrKu/KIoco8bzKuPI5lUxjfLiGsfvnlw==, + } + engines: { npm: '>= 7.0.0' } + peerDependencies: + '@swc/core': '*' + + '@swc/types@0.1.6': + resolution: + { + integrity: sha512-/JLo/l2JsT/LRd80C3HfbmVpxOAJ11FO2RCEslFrgzLltoP9j8XIbsyDcfCt2WWyX+CM96rBoNM+IToAkFOugg==, + } + + '@tokenizer/inflate@0.4.1': + resolution: + { + integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==, + } + engines: { node: '>=18' } + + '@tokenizer/token@0.3.0': + resolution: + { + integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==, + } + + '@tsconfig/node10@1.0.8': + resolution: + { + integrity: sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==, + } + + '@tsconfig/node12@1.0.9': + resolution: + { + integrity: sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==, + } + + '@tsconfig/node14@1.0.1': + resolution: + { + integrity: sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==, + } + + '@tsconfig/node16@1.0.2': + resolution: + { + integrity: sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==, + } + + '@types/aws-lambda@8.10.163': + resolution: + { + integrity: sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==, + } + + '@types/babel__core@7.20.5': + resolution: + { + integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==, + } + + '@types/babel__generator@7.6.8': + resolution: + { + integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==, + } + + '@types/babel__template@7.4.4': + resolution: + { + integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==, + } + + '@types/babel__traverse@7.20.4': + resolution: + { + integrity: sha512-mSM/iKUk5fDDrEV/e83qY+Cr3I1+Q3qqTuEn++HAWYjEa1+NxZr6CNrcJGf2ZTnq4HoFGC3zaTPZTobCzCFukA==, + } + + '@types/chai@5.2.3': + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, + } + + '@types/connect@3.4.38': + resolution: + { + integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==, + } + + '@types/deep-eql@4.0.2': + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } + + '@types/estree@1.0.8': + resolution: + { + integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, + } + + '@types/estree@1.0.9': + resolution: + { + integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, + } + + '@types/graceful-fs@4.1.9': + resolution: + { + integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==, + } + + '@types/hast@3.0.5': + resolution: + { + integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==, + } + + '@types/istanbul-lib-coverage@2.0.6': + resolution: + { + integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==, + } + + '@types/istanbul-lib-report@3.0.3': + resolution: + { + integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==, + } + + '@types/istanbul-reports@3.0.4': + resolution: + { + integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==, + } + + '@types/jest@29.5.11': + resolution: + { + integrity: sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==, + } + + '@types/json-schema@7.0.15': + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } + + '@types/memcached@2.2.10': + resolution: + { + integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==, + } + + '@types/node-fetch@2.6.13': + resolution: + { + integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==, + } + + '@types/node@25.9.8': + resolution: + { + integrity: sha512-VfMrScDmMhUJQmd5hArdQnFvK0OIeD36uN2Va1FcpYaLB/BgkgM8Ulc50XtYISPMz7APJ30+N0T5EM0jlcdfRw==, + } + + '@types/pg-pool@2.0.7': + resolution: + { + integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==, + } + + '@types/pg@8.15.6': + resolution: + { + integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==, + } + + '@types/retry@0.12.0': + resolution: + { + integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==, + } + + '@types/semver@7.8.0': + resolution: + { + integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==, + } + + '@types/set-cookie-parser@2.4.10': + resolution: + { + integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==, + } + + '@types/stack-utils@2.0.3': + resolution: + { + integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==, + } + + '@types/statuses@2.0.6': + resolution: + { + integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==, + } + + '@types/unist@3.0.3': + resolution: + { + integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==, + } + + '@types/webidl-conversions@7.0.3': + resolution: + { + integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==, + } + + '@types/whatwg-url@13.0.0': + resolution: + { + integrity: sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==, + } + + '@types/ws@8.18.1': + resolution: + { + integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==, + } + + '@types/yargs-parser@21.0.3': + resolution: + { + integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==, + } + + '@types/yargs@17.0.32': + resolution: + { + integrity: sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==, + } + + '@types/yargs@17.0.35': + resolution: + { + integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==, + } + + '@typescript-eslint/eslint-plugin@8.31.1': + resolution: + { + integrity: sha512-oUlH4h1ABavI4F0Xnl8/fOtML/eu8nI2A1nYd+f+55XI0BLu+RIqKoCiZKNo6DtqZBEQm5aNKA20G3Z5w3R6GQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/parser@8.31.1': + resolution: + { + integrity: sha512-oU/OtYVydhXnumd0BobL9rkJg7wFJ9bFFPmSmB/bf/XWN85hlViji59ko6bSKBXyseT9V8l+CN1nwmlbiN0G7Q==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/scope-manager@8.31.1': + resolution: + { + integrity: sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/type-utils@8.31.1': + resolution: + { + integrity: sha512-fNaT/m9n0+dpSp8G/iOQ05GoHYXbxw81x+yvr7TArTuZuCA6VVKbqWYVZrV5dVagpDTtj/O8k5HBEE/p/HM5LA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/types@8.31.1': + resolution: + { + integrity: sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/typescript-estree@8.31.1': + resolution: + { + integrity: sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/utils@8.31.1': + resolution: + { + integrity: sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + '@typescript-eslint/visitor-keys@8.31.1': + resolution: + { + integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@ungap/structured-clone@1.4.0': + resolution: + { + integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==, + } + + '@vercel/oidc@3.2.0': + resolution: + { + integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==, + } + engines: { node: '>= 20' } + + '@vercel/sandbox@2.9.2': + resolution: + { + integrity: sha512-tnPtCNL6MZKM9eRYvmyL0geA2Nifq+PSxVXBNhk/lQNeCWgMp/EYzCDOotKEWR/VH+c0Yv9HG4qk0w2I65xnLA==, + } + + '@vitest/expect@4.1.11': + resolution: + { + integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==, + } + + '@vitest/mocker@4.1.11': + resolution: + { + integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^7.3.2 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: + { + integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==, + } + + '@vitest/runner@4.1.11': + resolution: + { + integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==, + } + + '@vitest/snapshot@4.1.11': + resolution: + { + integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==, + } + + '@vitest/spy@4.1.11': + resolution: + { + integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==, + } + + '@vitest/utils@4.1.11': + resolution: + { + integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==, + } + + '@workflow/serde@4.1.0': + resolution: + { + integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==, + } + + '@workflow/serde@4.1.0-beta.2': + resolution: + { + integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==, + } + + accepts@2.0.0: + resolution: + { + integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==, + } + engines: { node: '>= 0.6' } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn-walk@8.2.0: + resolution: + { + integrity: sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==, + } + engines: { node: '>=0.4.0' } + + acorn@8.14.0: + resolution: + { + integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==, + } + engines: { node: '>=0.4.0' } + hasBin: true + + acorn@8.15.0: + resolution: + { + integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==, + } + engines: { node: '>=0.4.0' } + hasBin: true + + agent-base@7.1.4: + resolution: + { + integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==, + } + engines: { node: '>= 14' } + + aggregate-error@3.1.0: + resolution: + { + integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==, + } + engines: { node: '>=8' } + + ai@7.0.35: + resolution: + { + integrity: sha512-en8xnTNoVJVWk/Y7TsXuTkc463ehuQUqrofjPs/TAd4O3h14QKmq4rWWG3/Eb05r3f2MvvxfCuXNnvhTdzyoTg==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ai@7.0.36: + resolution: + { + integrity: sha512-1XJjua58GVQ0CyO2Xbioyladt85x71Joup2U8qKrjHUl8tHYwrDw8iFRtav6e94AxSVCn9FVgTS17oX1OCquKA==, + } + engines: { node: '>=22' } + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + + ajv-formats@3.0.1: + resolution: + { + integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==, + } + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.15.0: + resolution: + { + integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, + } + + ajv@8.20.0: + resolution: + { + integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==, + } + + ansi-escapes@4.3.2: + resolution: + { + integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==, + } + engines: { node: '>=8' } + + ansi-escapes@7.0.0: + resolution: + { + integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==, + } + engines: { node: '>=18' } + + ansi-regex@5.0.1: + resolution: + { + integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==, + } + engines: { node: '>=8' } + + ansi-regex@6.1.0: + resolution: + { + integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==, + } + engines: { node: '>=12' } + + ansi-regex@6.3.0: + resolution: + { + integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==, + } + engines: { node: '>=12' } + + ansi-styles@4.3.0: + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, + } + engines: { node: '>=8' } + + ansi-styles@5.2.0: + resolution: + { + integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==, + } + engines: { node: '>=10' } + + ansi-styles@6.2.3: + resolution: + { + integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==, + } + engines: { node: '>=12' } + + any-promise@1.3.0: + resolution: + { + integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==, + } + + anymatch@3.1.3: + resolution: + { + integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==, + } + engines: { node: '>= 8' } + + anynum@1.0.1: + resolution: + { + integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==, + } + + arg@4.1.3: + resolution: + { + integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==, + } + + argparse@2.0.1: + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } + + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: '>=12' } + + async-retry@1.3.3: + resolution: + { + integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==, + } + + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + + b4a@1.9.0: + resolution: + { + integrity: sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==, + } + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + babel-jest@29.7.0: + resolution: + { + integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + '@babel/core': ^7.29.7 + + babel-plugin-istanbul@6.1.1: + resolution: + { + integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==, + } + engines: { node: '>=8' } + + babel-plugin-istanbul@8.0.0: + resolution: + { + integrity: sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==, + } + engines: { node: '>=18' } + + babel-plugin-jest-hoist@29.6.3: + resolution: + { + integrity: sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + babel-preset-current-node-syntax@1.2.0: + resolution: + { + integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==, + } + peerDependencies: + '@babel/core': ^7.29.7 + + babel-preset-jest@29.6.3: + resolution: + { + integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + '@babel/core': ^7.29.7 + + balanced-match@1.0.2: + resolution: + { + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, + } + + bare-events@2.9.2: + resolution: + { + integrity: sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==, + } + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + base64-js@1.5.1: + resolution: + { + integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==, + } + + baseline-browser-mapping@2.11.24: + resolution: + { + integrity: sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + bignumber.js@9.3.1: + resolution: + { + integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==, + } + + bl@4.1.0: + resolution: + { + integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==, + } + + body-parser@2.3.0: + resolution: + { + integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==, + } + engines: { node: '>=18' } + + bowser@2.14.1: + resolution: + { + integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==, + } + + brace-expansion@2.1.4: + resolution: + { + integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==, + } + + braces@3.0.3: + resolution: + { + integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==, + } + engines: { node: '>=8' } + + browserslist@4.29.0: + resolution: + { + integrity: sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==, + } + engines: { node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7 } + hasBin: true + + bs-logger@0.2.6: + resolution: + { + integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==, + } + engines: { node: '>= 6' } + + bser@2.1.1: + resolution: + { + integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==, + } + + bson@7.3.3: + resolution: + { + integrity: sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==, + } + engines: { node: '>=20.19.0' } + + buffer-equal-constant-time@1.0.1: + resolution: + { + integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==, + } + + buffer-from@1.1.2: + resolution: + { + integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==, + } + + buffer@5.7.1: + resolution: + { + integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==, + } + + bytes@3.1.2: + resolution: + { + integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==, + } + engines: { node: '>= 0.8' } + + call-bind-apply-helpers@1.0.2: + resolution: + { + integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==, + } + engines: { node: '>= 0.4' } + + call-bound@1.0.4: + resolution: + { + integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==, + } + engines: { node: '>= 0.4' } + + callsites@3.1.0: + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: '>=6' } + + camelcase@5.3.1: + resolution: + { + integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==, + } + engines: { node: '>=6' } + + camelcase@6.3.0: + resolution: + { + integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==, + } + engines: { node: '>=10' } + + caniuse-lite@1.0.30001810: + resolution: + { + integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==, + } + + chai@6.2.2: + resolution: + { + integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==, + } + engines: { node: '>=18' } + + chalk@4.1.2: + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, + } + engines: { node: '>=10' } + + chalk@5.3.0: + resolution: + { + integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==, + } + engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } + + chalk@5.6.2: + resolution: + { + integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==, + } + engines: { node: ^12.17.0 || ^14.13 || >=16.0.0 } + + char-regex@1.0.2: + resolution: + { + integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==, + } + engines: { node: '>=10' } + + chownr@1.1.4: + resolution: + { + integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, + } + + ci-info@3.9.0: + resolution: + { + integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==, + } + engines: { node: '>=8' } + + ci-info@4.4.0: + resolution: + { + integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==, + } + engines: { node: '>=8' } + + cjs-module-lexer@1.4.1: + resolution: + { + integrity: sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==, + } + + cjs-module-lexer@2.2.1: + resolution: + { + integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==, + } + + clean-stack@2.2.0: + resolution: + { + integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==, + } + engines: { node: '>=6' } + + cli-highlight@2.1.11: + resolution: + { + integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==, + } + engines: { node: '>=8.0.0', npm: '>=5.0.0' } + hasBin: true + + cli-table3@0.6.5: + resolution: + { + integrity: sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==, + } + engines: { node: 10.* || >= 12.* } + + cli-width@4.1.0: + resolution: + { + integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==, + } + engines: { node: '>= 12' } + + cliui@7.0.4: + resolution: + { + integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==, + } + + cliui@8.0.1: + resolution: + { + integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==, + } + engines: { node: '>=12' } + + co@4.6.0: + resolution: + { + integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==, + } + engines: { iojs: '>= 1.0.0', node: '>= 0.12.0' } + + collect-v8-coverage@1.0.2: + resolution: + { + integrity: sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==, + } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: '>=7.0.0' } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } + + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: '>= 0.8' } + + commander@10.0.1: + resolution: + { + integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==, + } + engines: { node: '>=14' } + + commander@6.2.1: + resolution: + { + integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==, + } + engines: { node: '>= 6' } + + content-disposition@1.1.0: + resolution: + { + integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==, + } + engines: { node: '>=18' } + + content-type@1.0.5: + resolution: + { + integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==, + } + engines: { node: '>= 0.6' } + + content-type@2.1.0: + resolution: + { + integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==, + } + engines: { node: '>=18' } + + convert-source-map@2.0.0: + resolution: + { + integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==, + } + + cookie-signature@1.2.2: + resolution: + { + integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==, + } + engines: { node: '>=6.6.0' } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, + } + engines: { node: '>= 0.6' } + + cookie@1.1.1: + resolution: + { + integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==, + } + engines: { node: '>=18' } + + cors@2.8.6: + resolution: + { + integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==, + } + engines: { node: '>= 0.10' } + + create-jest@29.7.0: + resolution: + { + integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + hasBin: true + + create-require@1.1.1: + resolution: + { + integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==, + } + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: '>= 8' } + + data-uri-to-buffer@4.0.1: + resolution: + { + integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==, + } + engines: { node: '>= 12' } + + debug@4.4.1: + resolution: + { + integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==, + } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decamelize@1.2.0: + resolution: + { + integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==, + } + engines: { node: '>=0.10.0' } + + decompress-response@6.0.0: + resolution: + { + integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==, + } + engines: { node: '>=10' } + + dedent@1.5.1: + resolution: + { + integrity: sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==, + } + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-extend@0.6.0: + resolution: + { + integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==, + } + engines: { node: '>=4.0.0' } + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } + + deepmerge@4.3.1: + resolution: + { + integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==, + } + engines: { node: '>=0.10.0' } + + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, + } + engines: { node: '>=0.4.0' } + + depd@2.0.0: + resolution: + { + integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==, + } + engines: { node: '>= 0.8' } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: '>=8' } + + detect-newline@3.1.0: + resolution: + { + integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==, + } + engines: { node: '>=8' } + + diff-sequences@29.6.3: + resolution: + { + integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + diff@4.0.4: + resolution: + { + integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==, + } + engines: { node: '>=0.3.1' } + + diff@8.0.4: + resolution: + { + integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==, + } + engines: { node: '>=0.3.1' } + + dotenv@17.4.2: + resolution: + { + integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==, + } + engines: { node: '>=12' } + + dunder-proto@1.0.1: + resolution: + { + integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, + } + engines: { node: '>= 0.4' } + + eastasianwidth@0.2.0: + resolution: + { + integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==, + } + + ecdsa-sig-formatter@1.0.11: + resolution: + { + integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==, + } + + ee-first@1.1.1: + resolution: + { + integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==, + } + + electron-to-chromium@1.5.429: + resolution: + { + integrity: sha512-/1ENIE3cx4HTIx4IfPZFaOunJmsrSVTnj6coXoRVbiJUbkeTyFkJvBeWGkdgh08OhFbxYLMT1kbkwFVSarq6Ow==, + } + + emittery@0.13.1: + resolution: + { + integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==, + } + engines: { node: '>=12' } + + emoji-regex@8.0.0: + resolution: + { + integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==, + } + + emoji-regex@9.2.2: + resolution: + { + integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==, + } + + emojilib@2.4.0: + resolution: + { + integrity: sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==, + } + + encodeurl@2.0.0: + resolution: + { + integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==, + } + engines: { node: '>= 0.8' } + + end-of-stream@1.4.5: + resolution: + { + integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==, + } + + entities@4.5.0: + resolution: + { + integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==, + } + engines: { node: '>=0.12' } + + environment@1.1.0: + resolution: + { + integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==, + } + engines: { node: '>=18' } + + error-ex@1.3.2: + resolution: + { + integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==, + } + + es-define-property@1.0.1: + resolution: + { + integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, + } + engines: { node: '>= 0.4' } + + es-errors@1.3.0: + resolution: + { + integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==, + } + engines: { node: '>= 0.4' } + + es-module-lexer@2.3.2: + resolution: + { + integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==, + } + + es-module-lexer@3.0.2: + resolution: + { + integrity: sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==, + } + + es-object-atoms@1.1.2: + resolution: + { + integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==, + } + engines: { node: '>= 0.4' } + + es-set-tostringtag@2.1.0: + resolution: + { + integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==, + } + engines: { node: '>= 0.4' } + + esbuild@0.28.2: + resolution: + { + integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==, + } + engines: { node: '>=18' } + hasBin: true + + escalade@3.1.1: + resolution: + { + integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==, + } + engines: { node: '>=6' } + + escalade@3.2.0: + resolution: + { + integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==, + } + engines: { node: '>=6' } + + escape-html@1.0.3: + resolution: + { + integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==, + } + + escape-string-regexp@2.0.0: + resolution: + { + integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==, + } + engines: { node: '>=8' } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: '>=10' } + + eslint-plugin-unused-imports@4.1.4: + resolution: + { + integrity: sha512-YptD6IzQjDardkl0POxnnRBhU1OEePMV0nd6siHaRBbd+lyh6NAhFEobiznKU7kTsSsDeSD62Pe7kAM1b7dAZQ==, + } + peerDependencies: + '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + + eslint-scope@8.4.0: + resolution: + { + integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@4.2.0: + resolution: + { + integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@4.2.1: + resolution: + { + integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint@9.39.1: + resolution: + { + integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: + { + integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + esquery@1.6.0: + resolution: + { + integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==, + } + engines: { node: '>=0.10' } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: '>=4.0' } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: '>=4.0' } + + estree-walker@3.0.3: + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: '>=0.10.0' } + + etag@1.8.1: + resolution: + { + integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==, + } + engines: { node: '>= 0.6' } + + eventemitter3@4.0.7: + resolution: + { + integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==, + } + + events-universal@1.0.1: + resolution: + { + integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==, + } + + eventsource-parser@3.1.1: + resolution: + { + integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==, + } + engines: { node: '>=18.0.0' } + + eventsource@3.0.7: + resolution: + { + integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==, + } + engines: { node: '>=18.0.0' } + + execa@5.1.1: + resolution: + { + integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==, + } + engines: { node: '>=10' } + + exit-x@0.2.2: + resolution: + { + integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==, + } + engines: { node: '>= 0.8.0' } + + exit@0.1.2: + resolution: + { + integrity: sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==, + } + engines: { node: '>= 0.8.0' } + + expand-template@2.0.3: + resolution: + { + integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==, + } + engines: { node: '>=6' } + + expect-type@1.4.0: + resolution: + { + integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==, + } + engines: { node: '>=12.0.0' } + + expect@29.7.0: + resolution: + { + integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + expect@30.5.2: + resolution: + { + integrity: sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + express-rate-limit@8.7.0: + resolution: + { + integrity: sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==, + } + engines: { node: '>= 16' } + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: + { + integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==, + } + engines: { node: '>= 18' } + + extend@3.0.2: + resolution: + { + integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==, + } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-fifo@1.3.2: + resolution: + { + integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==, + } + + fast-glob@3.3.2: + resolution: + { + integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==, + } + engines: { node: '>=8.6.0' } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } + + fast-sha256@1.3.0: + resolution: + { + integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==, + } + + fast-string-truncated-width@3.0.3: + resolution: + { + integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==, + } + + fast-string-width@3.0.2: + resolution: + { + integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==, + } + + fast-uri@3.1.8: + resolution: + { + integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==, + } + + fast-wrap-ansi@0.2.2: + resolution: + { + integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==, + } + + fast-xml-builder@1.3.1: + resolution: + { + integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==, + } + + fast-xml-parser@5.11.1: + resolution: + { + integrity: sha512-TBw6K/fxoQGGjCmZDw9w/ZwP3uDcnTM4YH/g+PFRWr8sbe5idXtxNN6vITh4+1ruCZaho6uBFurElsA7F0zzgw==, + } + hasBin: true + + fastq@1.17.1: + resolution: + { + integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==, + } + + fb-watchman@2.0.2: + resolution: + { + integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: '>=12.0.0' } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: + { + integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==, + } + engines: { node: ^12.20 || >= 14.13 } + + fflate@0.8.3: + resolution: + { + integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==, + } + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: '>=16.0.0' } + + file-type@21.3.4: + resolution: + { + integrity: sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==, + } + engines: { node: '>=20' } + + fill-range@7.1.1: + resolution: + { + integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==, + } + engines: { node: '>=8' } + + finalhandler@2.1.1: + resolution: + { + integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==, + } + engines: { node: '>= 18.0.0' } + + find-up@4.1.0: + resolution: + { + integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==, + } + engines: { node: '>=8' } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: '>=10' } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: '>=16' } + + flatted@3.4.4: + resolution: + { + integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==, + } + + foreground-child@3.3.1: + resolution: + { + integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==, + } + engines: { node: '>=14' } + + form-data@4.0.6: + resolution: + { + integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==, + } + engines: { node: '>= 6' } + + formdata-polyfill@4.0.10: + resolution: + { + integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==, + } + engines: { node: '>=12.20.0' } + + forwarded-parse@2.1.2: + resolution: + { + integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==, + } + + forwarded@0.2.0: + resolution: + { + integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==, + } + engines: { node: '>= 0.6' } + + fresh@2.0.0: + resolution: + { + integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==, + } + engines: { node: '>= 0.8' } + + fs-constants@1.0.0: + resolution: + { + integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==, + } + + fs.realpath@1.0.0: + resolution: + { + integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==, + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + function-bind@1.1.2: + resolution: + { + integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==, + } + + gaxios@7.1.3: + resolution: + { + integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==, + } + engines: { node: '>=18' } + + gaxios@7.3.1: + resolution: + { + integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==, + } + engines: { node: '>=18' } + + gcp-metadata@8.1.2: + resolution: + { + integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==, + } + engines: { node: '>=18' } + + gcp-metadata@8.1.4: + resolution: + { + integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==, + } + engines: { node: '>=18' } + + gensync@1.0.0-beta.2: + resolution: + { + integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==, + } + engines: { node: '>=6.9.0' } + + get-caller-file@2.0.5: + resolution: + { + integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==, + } + engines: { node: 6.* || 8.* || >= 10.* } + + get-east-asian-width@1.6.0: + resolution: + { + integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==, + } + engines: { node: '>=18' } + + get-intrinsic@1.3.0: + resolution: + { + integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==, + } + engines: { node: '>= 0.4' } + + get-package-type@0.1.0: + resolution: + { + integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==, + } + engines: { node: '>=8.0.0' } + + get-proto@1.0.1: + resolution: + { + integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==, + } + engines: { node: '>= 0.4' } + + get-stdin@8.0.0: + resolution: + { + integrity: sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==, + } + engines: { node: '>=10' } + + get-stream@6.0.1: + resolution: + { + integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==, + } + engines: { node: '>=10' } + + github-from-package@0.0.0: + resolution: + { + integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==, + } + + glob-parent@5.1.2: + resolution: + { + integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==, + } + engines: { node: '>= 6' } + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: '>=10.13.0' } + + glob@10.5.0: + resolution: + { + integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==, + } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: + { + integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==, + } + engines: { node: 18 || 20 || >=22 } + + glob@7.2.3: + resolution: + { + integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==, + } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: + { + integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==, + } + engines: { node: '>=12' } + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + globals@14.0.0: + resolution: + { + integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, + } + engines: { node: '>=18' } + + google-auth-library@10.9.1: + resolution: + { + integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==, + } + engines: { node: '>=18' } + + google-logging-utils@1.1.3: + resolution: + { + integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==, + } + engines: { node: '>=14' } + + gopd@1.2.0: + resolution: + { + integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==, + } + engines: { node: '>= 0.4' } + + graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, + } + + graphemer@1.4.0: + resolution: + { + integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==, + } + + graphql@16.14.2: + resolution: + { + integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==, + } + engines: { node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0 } + + handlebars@4.7.9: + resolution: + { + integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==, + } + engines: { node: '>=0.4.7' } + hasBin: true + + has-flag@4.0.0: + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, + } + engines: { node: '>=8' } + + has-symbols@1.1.0: + resolution: + { + integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==, + } + engines: { node: '>= 0.4' } + + has-tostringtag@1.0.2: + resolution: + { + integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==, + } + engines: { node: '>= 0.4' } + + hasown@2.0.0: + resolution: + { + integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==, + } + engines: { node: '>= 0.4' } + + hasown@2.0.4: + resolution: + { + integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==, + } + engines: { node: '>= 0.4' } + + headers-polyfill@5.0.1: + resolution: + { + integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==, + } + + highlight.js@10.7.3: + resolution: + { + integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==, + } + + hono@4.13.5: + resolution: + { + integrity: sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw==, + } + engines: { node: '>=16.9.0' } + + hosted-git-info@9.0.3: + resolution: + { + integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==, + } + engines: { node: ^20.17.0 || >=22.9.0 } + + html-escaper@2.0.2: + resolution: + { + integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==, + } + + http-errors@2.0.1: + resolution: + { + integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==, + } + engines: { node: '>= 0.8' } + + http-proxy-agent@7.0.2: + resolution: + { + integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==, + } + engines: { node: '>= 14' } + + https-proxy-agent@7.0.6: + resolution: + { + integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==, + } + engines: { node: '>= 14' } + + human-signals@2.1.0: + resolution: + { + integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, + } + engines: { node: '>=10.17.0' } + + iconv-lite@0.6.3: + resolution: + { + integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==, + } + engines: { node: '>=0.10.0' } + + iconv-lite@0.7.3: + resolution: + { + integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==, + } + engines: { node: '>=0.10.0' } + + ieee754@1.2.1: + resolution: + { + integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==, + } + + ignore-walk@5.0.1: + resolution: + { + integrity: sha512-yemi4pMf51WKT7khInJqAvsIGzoqYXblnsz0ql8tM+yi1EKYTY1evX4NAbJrLL/Aanr2HyZeluqU+Oi7MGHokw==, + } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: '>= 4' } + + ignore@7.0.5: + resolution: + { + integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==, + } + engines: { node: '>= 4' } + + import-fresh@3.3.1: + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, + } + engines: { node: '>=6' } + + import-in-the-middle@3.5.1: + resolution: + { + integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==, + } + engines: { node: '>=18' } + + import-local@3.1.0: + resolution: + { + integrity: sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==, + } + engines: { node: '>=8' } + hasBin: true + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: '>=0.8.19' } + + indent-string@4.0.0: + resolution: + { + integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==, + } + engines: { node: '>=8' } + + inflight@1.0.6: + resolution: + { + integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==, + } + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: + { + integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==, + } + + ini@1.3.8: + resolution: + { + integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, + } + + ini@6.0.0: + resolution: + { + integrity: sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==, + } + engines: { node: ^20.17.0 || >=22.9.0 } + + ip-address@10.7.1: + resolution: + { + integrity: sha512-4OUAqU9Z1i3vCnS05hzGiFnEMDpQ+62pAD/MVQOp83fYyNC8GleCqaS0QikQBmcWCrKFiUs/B8ztRRiYOAXuCA==, + } + engines: { node: '>= 12' } + + ipaddr.js@1.9.1: + resolution: + { + integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==, + } + engines: { node: '>= 0.10' } + + is-arrayish@0.2.1: + resolution: + { + integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==, + } + + is-core-module@2.13.1: + resolution: + { + integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==, + } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: '>=0.10.0' } + + is-fullwidth-code-point@3.0.0: + resolution: + { + integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==, + } + engines: { node: '>=8' } + + is-generator-fn@2.1.0: + resolution: + { + integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==, + } + engines: { node: '>=6' } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: '>=0.10.0' } + + is-node-process@1.2.0: + resolution: + { + integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==, + } + + is-number@7.0.0: + resolution: + { + integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==, + } + engines: { node: '>=0.12.0' } + + is-promise@4.0.0: + resolution: + { + integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==, + } + + is-stream@2.0.1: + resolution: + { + integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==, + } + engines: { node: '>=8' } + + is-unsafe@2.0.2: + resolution: + { + integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==, + } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + istanbul-lib-coverage@3.2.2: + resolution: + { + integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==, + } + engines: { node: '>=8' } + + istanbul-lib-instrument@5.2.1: + resolution: + { + integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==, + } + engines: { node: '>=8' } + + istanbul-lib-instrument@6.0.3: + resolution: + { + integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==, + } + engines: { node: '>=10' } + + istanbul-lib-report@3.0.1: + resolution: + { + integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==, + } + engines: { node: '>=10' } + + istanbul-lib-source-maps@4.0.1: + resolution: + { + integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==, + } + engines: { node: '>=10' } + + istanbul-lib-source-maps@5.0.6: + resolution: + { + integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==, + } + engines: { node: '>=10' } + + istanbul-reports@3.1.6: + resolution: + { + integrity: sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==, + } + engines: { node: '>=8' } + + jackspeak@3.4.3: + resolution: + { + integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==, + } + + jest-changed-files@29.7.0: + resolution: + { + integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-circus@29.7.0: + resolution: + { + integrity: sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-cli@29.7.0: + resolution: + { + integrity: sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@29.7.0: + resolution: + { + integrity: sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + peerDependencies: + '@types/node': '*' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + ts-node: + optional: true + + jest-diff@29.7.0: + resolution: + { + integrity: sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-diff@30.5.2: + resolution: + { + integrity: sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-docblock@29.7.0: + resolution: + { + integrity: sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-each@29.7.0: + resolution: + { + integrity: sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-environment-node@29.7.0: + resolution: + { + integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-get-type@29.6.3: + resolution: + { + integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-haste-map@29.7.0: + resolution: + { + integrity: sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-haste-map@30.5.1: + resolution: + { + integrity: sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-leak-detector@29.7.0: + resolution: + { + integrity: sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-matcher-utils@29.7.0: + resolution: + { + integrity: sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-matcher-utils@30.5.2: + resolution: + { + integrity: sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-message-util@29.7.0: + resolution: + { + integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-message-util@30.5.1: + resolution: + { + integrity: sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-mock@29.7.0: + resolution: + { + integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-mock@30.5.2: + resolution: + { + integrity: sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-pnp-resolver@1.2.3: + resolution: + { + integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==, + } + engines: { node: '>=6' } + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@29.6.3: + resolution: + { + integrity: sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-regex-util@30.5.0: + resolution: + { + integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-resolve-dependencies@29.7.0: + resolution: + { + integrity: sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-resolve@29.7.0: + resolution: + { + integrity: sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-runner@29.7.0: + resolution: + { + integrity: sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-runtime@29.7.0: + resolution: + { + integrity: sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-snapshot@29.7.0: + resolution: + { + integrity: sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-snapshot@30.5.2: + resolution: + { + integrity: sha512-jKIdes25AnAH73dOCi2qE6J6vHJ6L2vyBS11M89kT50DVJcLTf2AOjvcc+2vVaN8lUbY5v2R83aqXo3Ro9fqYA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-util@29.7.0: + resolution: + { + integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-util@30.5.1: + resolution: + { + integrity: sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest-validate@29.7.0: + resolution: + { + integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-watcher@29.7.0: + resolution: + { + integrity: sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-worker@29.7.0: + resolution: + { + integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + jest-worker@30.5.1: + resolution: + { + integrity: sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + jest@29.7.0: + resolution: + { + integrity: sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jiti@2.7.0: + resolution: + { + integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==, + } + hasBin: true + + jose@6.2.12: + resolution: + { + integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==, + } + + jose@6.2.3: + resolution: + { + integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==, + } + + js-tiktoken@1.0.21: + resolution: + { + integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==, + } + + js-tokens@4.0.0: + resolution: + { + integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==, + } + + js-yaml@4.3.2: + resolution: + { + integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==, + } + hasBin: true + + jsesc@3.1.0: + resolution: + { + integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==, + } + engines: { node: '>=6' } + hasBin: true + + json-bigint@1.0.0: + resolution: + { + integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==, + } + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } + + json-parse-even-better-errors@2.3.1: + resolution: + { + integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==, + } + + json-schema-to-ts@3.1.1: + resolution: + { + integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==, + } + engines: { node: '>=16' } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-schema-traverse@1.0.0: + resolution: + { + integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, + } + + json-schema-typed@8.0.2: + resolution: + { + integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==, + } + + json-schema@0.4.0: + resolution: + { + integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + json5@2.2.3: + resolution: + { + integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==, + } + engines: { node: '>=6' } + hasBin: true + + jsonc-parser@3.2.1: + resolution: + { + integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==, + } + + jsonlines@0.1.1: + resolution: + { + integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==, + } + + jsonpointer@5.0.1: + resolution: + { + integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==, + } + engines: { node: '>=0.10.0' } + + just-bash@2.14.5: + resolution: + { + integrity: sha512-MCBGnRlDeZ/MM7mcw+ZuSGFMBsggajrmKz6e/hrOAN7syvVZkjiY+Vh2wyCwN/CdcnAX5SxbiQB51n5nrQuX+g==, + } + hasBin: true + + jwa@2.0.1: + resolution: + { + integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==, + } + + jws@4.0.1: + resolution: + { + integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==, + } + + kareem@3.4.0: + resolution: + { + integrity: sha512-JAKhnR4S027v/8949QXNInRskgETCg9rFUMi1VjhGbjX+XltCAAEFcXykkSocNkom1kc66EMJfjWvhj87BDQKg==, + } + engines: { node: '>=18.0.0' } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } + + kleur@3.0.3: + resolution: + { + integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==, + } + engines: { node: '>=6' } + + langchain@0.3.37: + resolution: + { + integrity: sha512-1jPsZ6xsxkcQPUvqRjvfuOLwZLLyt49hzcOK7OYAJovIkkOxd5gzK4Yw6giPUQ8g4XHyvULNlWBz+subdkcokw==, + } + engines: { node: '>=18' } + peerDependencies: + '@langchain/anthropic': '*' + '@langchain/aws': '*' + '@langchain/cerebras': '*' + '@langchain/cohere': '*' + '@langchain/core': '>=0.3.58 <0.4.0' + '@langchain/deepseek': '*' + '@langchain/google-genai': '*' + '@langchain/google-vertexai': '*' + '@langchain/google-vertexai-web': '*' + '@langchain/groq': '*' + '@langchain/mistralai': '*' + '@langchain/ollama': '*' + '@langchain/xai': '*' + axios: '*' + cheerio: '*' + handlebars: ^4.7.8 + peggy: ^3.0.2 + typeorm: '*' + peerDependenciesMeta: + '@langchain/anthropic': + optional: true + '@langchain/aws': + optional: true + '@langchain/cerebras': + optional: true + '@langchain/cohere': + optional: true + '@langchain/deepseek': + optional: true + '@langchain/google-genai': + optional: true + '@langchain/google-vertexai': + optional: true + '@langchain/google-vertexai-web': + optional: true + '@langchain/groq': + optional: true + '@langchain/mistralai': + optional: true + '@langchain/ollama': + optional: true + '@langchain/xai': + optional: true + axios: + optional: true + cheerio: + optional: true + handlebars: + optional: true + peggy: + optional: true + typeorm: + optional: true + + langsmith@0.6.0: + resolution: + { + integrity: sha512-GGaj5IMRfLv2HXXFzGk9diISMYLTpSTh6fzCZGKxWYW/NqEztIFtnXLq6G/RVhzFRmCykLap1fuC67LVKoQLcg==, + } + peerDependencies: + '@opentelemetry/api': '*' + '@opentelemetry/exporter-trace-otlp-proto': '*' + '@opentelemetry/sdk-trace-base': '*' + openai: '*' + ws: '>=7' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/exporter-trace-otlp-proto': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + openai: + optional: true + ws: + optional: true + + leven@3.1.0: + resolution: + { + integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==, + } + engines: { node: '>=6' } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: '>= 0.8.0' } + + lines-and-columns@1.2.4: + resolution: + { + integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==, + } + + linkify-it@5.0.2: + resolution: + { + integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==, + } + + locate-path@5.0.0: + resolution: + { + integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==, + } + engines: { node: '>=8' } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: '>=10' } + + lodash.camelcase@4.3.0: + resolution: + { + integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==, + } + + lodash.memoize@4.1.2: + resolution: + { + integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==, + } + + lodash.merge@4.6.2: + resolution: + { + integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, + } + + long@5.3.2: + resolution: + { + integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==, + } + + lru-cache@10.4.3: + resolution: + { + integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==, + } + + lru-cache@11.5.2: + resolution: + { + integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==, + } + engines: { node: 20 || >=22 } + + lru-cache@5.1.1: + resolution: + { + integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==, + } + + lunr@2.3.9: + resolution: + { + integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==, + } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + + make-dir@4.0.0: + resolution: + { + integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==, + } + engines: { node: '>=10' } + + make-error@1.3.6: + resolution: + { + integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==, + } + + makeerror@1.0.12: + resolution: + { + integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==, + } + + markdown-it@14.3.2: + resolution: + { + integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==, + } + hasBin: true + + marked-terminal@7.2.1: + resolution: + { + integrity: sha512-rQ1MoMFXZICWNsKMiiHwP/Z+92PLKskTPXj+e7uwXmuMPkNn7iTqC+IvDekVm1MPeC9wYQeLxeFaOvudRR/XbQ==, + } + engines: { node: '>=16.0.0' } + peerDependencies: + marked: '>=1 <15' + + marked@18.0.5: + resolution: + { + integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==, + } + engines: { node: '>= 20' } + hasBin: true + + marked@9.1.6: + resolution: + { + integrity: sha512-jcByLnIFkd5gSXZmjNvS1TlmRhCXZjIzHYlaGkPlLIekG55JDR2Z4va9tZwCiP+/RDERiNhMOFu01xd6O5ct1Q==, + } + engines: { node: '>= 16' } + hasBin: true + + math-intrinsics@1.1.0: + resolution: + { + integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==, + } + engines: { node: '>= 0.4' } + + mdurl@2.1.0: + resolution: + { + integrity: sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==, + } + + media-typer@1.1.1: + resolution: + { + integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==, + } + engines: { node: '>= 0.8' } + + memory-pager@1.5.0: + resolution: + { + integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==, + } + + merge-descriptors@2.0.0: + resolution: + { + integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==, + } + engines: { node: '>=18' } + + merge-stream@2.0.0: + resolution: + { + integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==, + } + + merge2@1.4.1: + resolution: + { + integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==, + } + engines: { node: '>= 8' } + + micromatch@4.0.8: + resolution: + { + integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==, + } + engines: { node: '>=8.6' } + + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: '>= 0.6' } + + mime-db@1.54.0: + resolution: + { + integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==, + } + engines: { node: '>= 0.6' } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: '>= 0.6' } + + mime-types@3.0.2: + resolution: + { + integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==, + } + engines: { node: '>=18' } + + mimic-fn@2.1.0: + resolution: + { + integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==, + } + engines: { node: '>=6' } + + mimic-response@3.1.0: + resolution: + { + integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==, + } + engines: { node: '>=10' } + + minimatch@9.0.9: + resolution: + { + integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==, + } + engines: { node: '>=16 || 14 >=14.17' } + + minimist@1.2.6: + resolution: + { + integrity: sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==, + } + + minipass@7.1.3: + resolution: + { + integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==, + } + engines: { node: '>=16 || 14 >=14.17' } + + mkdirp-classic@0.5.3: + resolution: + { + integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==, + } + + modern-tar@0.7.7: + resolution: + { + integrity: sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==, + } + engines: { node: '>=18.0.0' } + + module-details-from-path@1.0.4: + resolution: + { + integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==, + } + + mongodb-connection-string-url@7.0.2: + resolution: + { + integrity: sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==, + } + engines: { node: '>=20.19.0' } + + mongodb@7.6.0: + resolution: + { + integrity: sha512-WbZ6OCjYw2c53LOjfkQa+reXr7kIiOVpXXglnASFuiMtif0BvsMwHe3ClJHLm1/r7wJFwaasfFtD6iYIktB01g==, + } + engines: { node: '>=20.19.0' } + peerDependencies: + '@aws-sdk/credential-providers': ^3.806.0 + '@mongodb-js/zstd': ^7.0.0 + gcp-metadata: ^7.0.1 + kerberos: ^7.0.0 + mongodb-client-encryption: ^7.2.0 + snappy: ^7.3.2 + socks: ^2.8.6 + peerDependenciesMeta: + '@aws-sdk/credential-providers': + optional: true + '@mongodb-js/zstd': + optional: true + gcp-metadata: + optional: true + kerberos: + optional: true + mongodb-client-encryption: + optional: true + snappy: + optional: true + socks: + optional: true + + mongoose@9.10.1: + resolution: + { + integrity: sha512-rRIEGbM3rUgXAY554CEsgp+IxImo6D3ILUr3SJDnC2PLPNTcNe2xxYvazSLtvvTjv3A4MgqvsxCj3ViF4mHUUQ==, + } + engines: { node: '>=20.19.0' } + + mpath@0.9.0: + resolution: + { + integrity: sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==, + } + engines: { node: '>=4.0.0' } + + mquery@6.0.0: + resolution: + { + integrity: sha512-b2KQNsmgtkscfeDgkYMcWGn9vZI9YoXh802VDEwE6qc50zxBFQ0Oo8ROkawbPAsXCY1/Z1yp0MagqsZStPWJjw==, + } + engines: { node: '>=20.19.0' } + + mri@1.2.0: + resolution: + { + integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==, + } + engines: { node: '>=4' } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + msw@2.15.0: + resolution: + { + integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==, + } + engines: { node: '>=18' } + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mustache@4.2.0: + resolution: + { + integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==, + } + hasBin: true + + mute-stream@3.0.0: + resolution: + { + integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==, + } + engines: { node: ^20.17.0 || >=22.9.0 } + + mz@2.7.0: + resolution: + { + integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==, + } + + nanoid@3.3.19: + resolution: + { + integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + napi-build-utils@2.0.0: + resolution: + { + integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==, + } + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + negotiator@1.1.0: + resolution: + { + integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==, + } + engines: { node: '>=18' } + + neo-async@2.6.2: + resolution: + { + integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==, + } + + node-abi@3.96.0: + resolution: + { + integrity: sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==, + } + engines: { node: '>=10' } + + node-addon-api@7.1.1: + resolution: + { + integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==, + } + + node-addon-api@8.9.2: + resolution: + { + integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==, + } + engines: { node: ^18 || ^20 || >= 21 } + + node-domexception@1.0.0: + resolution: + { + integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==, + } + engines: { node: '>=10.5.0' } + deprecated: Use your platform's native DOMException instead + + node-emoji@2.1.3: + resolution: + { + integrity: sha512-E2WEOVsgs7O16zsURJ/eH8BqhF029wGpEOnv7Urwdo2wmQanOACwJQh0devF9D9RhoZru0+9JXIS0dBXIAz+lA==, + } + engines: { node: '>=18' } + + node-fetch@3.3.2: + resolution: + { + integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==, + } + engines: { node: ^12.20.0 || ^14.13.1 || >=16.0.0 } + + node-gyp-build@4.8.4: + resolution: + { + integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==, + } + hasBin: true + + node-int64@0.4.0: + resolution: + { + integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==, + } + + node-liblzma@2.2.0: + resolution: + { + integrity: sha512-s0KzNOWwOJJgPG6wxg6cKohnAl9Wk/oW1KrQaVzJBjQwVcUGPQCzpR46Ximygjqj/3KhOrtJXnYMp/xYAXp75g==, + } + engines: { node: '>=16.0.0' } + hasBin: true + + node-releases@2.0.55: + resolution: + { + integrity: sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==, + } + engines: { node: '>=18' } + + normalize-path@3.0.0: + resolution: + { + integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==, + } + engines: { node: '>=0.10.0' } + + npm-bundled@2.0.1: + resolution: + { + integrity: sha512-gZLxXdjEzE/+mOstGDqR6b0EkhJ+kM6fxM6vUuckuctuVPh80Q6pw/rSZj9s4Gex9GxWtIicO1pc8DB9KZWudw==, + } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + + npm-normalize-package-bin@2.0.0: + resolution: + { + integrity: sha512-awzfKUO7v0FscrSpRoogyNm0sajikhBWpU0QMrW09AMi9n1PoKU6WaIqUzuJSQnpciZZmJ/jMZ2Egfmb/9LiWQ==, + } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + + npm-packlist@5.1.3: + resolution: + { + integrity: sha512-263/0NGrn32YFYi4J533qzrQ/krmmrWwhKkzwTuM4f/07ug51odoaNjUexxO4vxlzURHcmYMH1QjvHjsNDKLVg==, + } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + hasBin: true + + npm-run-path@4.0.1: + resolution: + { + integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==, + } + engines: { node: '>=8' } + + object-assign@4.1.1: + resolution: + { + integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==, + } + engines: { node: '>=0.10.0' } + + object-inspect@1.13.4: + resolution: + { + integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==, + } + engines: { node: '>= 0.4' } + + obug@2.2.1: + resolution: + { + integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==, + } + engines: { node: '>=12.20.0' } + + on-finished@2.4.1: + resolution: + { + integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==, + } + engines: { node: '>= 0.8' } + + once@1.4.0: + resolution: + { + integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==, + } + + onetime@5.1.2: + resolution: + { + integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==, + } + engines: { node: '>=6' } + + openai@5.12.2: + resolution: + { + integrity: sha512-xqzHHQch5Tws5PcKR2xsZGX9xtch+JQFz5zb14dGqlshmmDAFBFEWmeIpf7wVqWV+w7Emj7jRgkNJakyKE0tYQ==, + } + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openai@6.26.0: + resolution: + { + integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==, + } + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + openai@6.49.0: + resolution: + { + integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==, + } + peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true + ws: + optional: true + zod: + optional: true + + openapi-types@12.1.3: + resolution: + { + integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==, + } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: '>= 0.8.0' } + + os-paths@4.4.0: + resolution: + { + integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==, + } + engines: { node: '>= 6.0' } + + outvariant@1.4.3: + resolution: + { + integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==, + } + + p-all@3.0.0: + resolution: + { + integrity: sha512-qUZbvbBFVXm6uJ7U/WDiO0fv6waBMbjlCm4E66oZdRR+egswICarIdHyVSZZHudH8T5SF8x/JG0q0duFzPnlBw==, + } + engines: { node: '>=10' } + + p-finally@1.0.0: + resolution: + { + integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==, + } + engines: { node: '>=4' } + + p-limit@2.3.0: + resolution: + { + integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==, + } + engines: { node: '>=6' } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: '>=10' } + + p-locate@4.1.0: + resolution: + { + integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==, + } + engines: { node: '>=8' } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: '>=10' } + + p-map@4.0.0: + resolution: + { + integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==, + } + engines: { node: '>=10' } + + p-queue@6.6.2: + resolution: + { + integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==, + } + engines: { node: '>=8' } + + p-retry@4.6.2: + resolution: + { + integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==, + } + engines: { node: '>=8' } + + p-timeout@3.2.0: + resolution: + { + integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==, + } + engines: { node: '>=8' } + + p-try@2.2.0: + resolution: + { + integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==, + } + engines: { node: '>=6' } + + package-json-from-dist@1.0.1: + resolution: + { + integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==, + } + + papaparse@5.7.0: + resolution: + { + integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==, + } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: '>=6' } + + parse-json@5.2.0: + resolution: + { + integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==, + } + engines: { node: '>=8' } + + parse5-htmlparser2-tree-adapter@6.0.1: + resolution: + { + integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==, + } + + parse5@5.1.1: + resolution: + { + integrity: sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==, + } + + parse5@6.0.1: + resolution: + { + integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==, + } + + parseurl@1.3.3: + resolution: + { + integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==, + } + engines: { node: '>= 0.8' } + + partial-json@0.1.7: + resolution: + { + integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==, + } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: '>=8' } + + path-expression-matcher@1.6.2: + resolution: + { + integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==, + } + engines: { node: '>=14.0.0' } + + path-is-absolute@1.0.1: + resolution: + { + integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==, + } + engines: { node: '>=0.10.0' } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: '>=8' } + + path-parse@1.0.7: + resolution: + { + integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==, + } + + path-scurry@1.11.1: + resolution: + { + integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==, + } + engines: { node: '>=16 || 14 >=14.18' } + + path-scurry@2.0.2: + resolution: + { + integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==, + } + engines: { node: 18 || 20 || >=22 } + + path-to-regexp@6.3.0: + resolution: + { + integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, + } + + path-to-regexp@8.4.2: + resolution: + { + integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==, + } + + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } + + pg-int8@1.0.1: + resolution: + { + integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==, + } + engines: { node: '>=4.0.0' } + + pg-protocol@1.16.0: + resolution: + { + integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==, + } + + pg-types@2.2.0: + resolution: + { + integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==, + } + engines: { node: '>=4' } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@2.3.2: + resolution: + { + integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==, + } + engines: { node: '>=8.6' } + + picomatch@4.0.7: + resolution: + { + integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==, + } + engines: { node: '>=12' } + + pirates@4.0.6: + resolution: + { + integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==, + } + engines: { node: '>= 6' } + + pirates@4.0.7: + resolution: + { + integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==, + } + engines: { node: '>= 6' } + + pkce-challenge@5.0.1: + resolution: + { + integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==, + } + engines: { node: '>=16.20.0' } + + pkg-dir@4.2.0: + resolution: + { + integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==, + } + engines: { node: '>=8' } + + postcss@8.5.23: + resolution: + { + integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==, + } + engines: { node: ^10 || ^12 || >=14 } + + postgres-array@2.0.0: + resolution: + { + integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==, + } + engines: { node: '>=4' } + + postgres-bytea@1.0.1: + resolution: + { + integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==, + } + engines: { node: '>=0.10.0' } + + postgres-date@1.0.7: + resolution: + { + integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==, + } + engines: { node: '>=0.10.0' } + + postgres-interval@1.2.0: + resolution: + { + integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==, + } + engines: { node: '>=0.10.0' } + + prebuild-install@7.1.3: + resolution: + { + integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==, + } + engines: { node: '>=10' } + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: '>= 0.8.0' } + + prettier@3.1.1: + resolution: + { + integrity: sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==, + } + engines: { node: '>=14' } + hasBin: true + + pretty-format@29.7.0: + resolution: + { + integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==, + } + engines: { node: ^14.15.0 || ^16.10.0 || >=18.0.0 } + + pretty-format@30.5.1: + resolution: + { + integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==, + } + engines: { node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0 } + + prompts@2.4.2: + resolution: + { + integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==, + } + engines: { node: '>= 6' } + + proper-lockfile@4.1.2: + resolution: + { + integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==, + } + + protobufjs@7.6.6: + resolution: + { + integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==, + } + engines: { node: '>=12.0.0' } + + proxy-addr@2.0.8: + resolution: + { + integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==, + } + engines: { node: '>= 0.10' } + + publint@0.2.12: + resolution: + { + integrity: sha512-YNeUtCVeM4j9nDiTT2OPczmlyzOkIXNtdDZnSuajAxS/nZ6j3t7Vs9SUB4euQNddiltIwu7Tdd3s+hr08fAsMw==, + } + engines: { node: '>=16' } + hasBin: true + + pump@3.0.4: + resolution: + { + integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==, + } + + punycode.js@2.3.1: + resolution: + { + integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==, + } + engines: { node: '>=6' } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: '>=6' } + + pure-rand@6.0.4: + resolution: + { + integrity: sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==, + } + + qs@6.16.0: + resolution: + { + integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==, + } + engines: { node: '>=0.6' } + + queue-microtask@1.2.3: + resolution: + { + integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==, + } + + quickjs-emscripten-core@0.32.0: + resolution: + { + integrity: sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==, + } + + quickjs-emscripten@0.32.0: + resolution: + { + integrity: sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==, + } + engines: { node: '>=16.0.0' } + + range-parser@1.3.0: + resolution: + { + integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==, + } + engines: { node: '>= 0.6' } + + raw-body@3.0.2: + resolution: + { + integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==, + } + engines: { node: '>= 0.10' } + + rc@1.2.8: + resolution: + { + integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==, + } + hasBin: true + + re2js@1.3.3: + resolution: + { + integrity: sha512-s/I5zEAo79SUK0Qw4dpZKpiMwbQ6Gz0KU2NRr7eaO4x/p2g7Vvmn3hdeXDg8VsaUjfj/ora+e9oi27LX/C9+mw==, + } + + react-is@18.2.0: + resolution: + { + integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==, + } + + react-is@18.3.1: + resolution: + { + integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==, + } + + react-is@19.3.0: + resolution: + { + integrity: sha512-UpMYezM4v5/18F28aC66AEsjXIgE02kyEMH6yLdgLXu/UTfa1Ntwck/nNLrbqJsEXW7gPb0coNO9FQse9WTovA==, + } + + readable-stream@3.6.2: + resolution: + { + integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==, + } + engines: { node: '>= 6' } + + require-directory@2.1.1: + resolution: + { + integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, + } + engines: { node: '>=0.10.0' } + + require-from-string@2.0.2: + resolution: + { + integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, + } + engines: { node: '>=0.10.0' } + + require-in-the-middle@8.0.1: + resolution: + { + integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==, + } + engines: { node: '>=9.3.0 || >=8.10.0 <9.0.0' } + + resolve-cwd@3.0.0: + resolution: + { + integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==, + } + engines: { node: '>=8' } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: '>=4' } + + resolve-from@5.0.0: + resolution: + { + integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==, + } + engines: { node: '>=8' } + + resolve.exports@2.0.2: + resolution: + { + integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==, + } + engines: { node: '>=10' } + + resolve@1.22.8: + resolution: + { + integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==, + } + hasBin: true + + retry@0.12.0: + resolution: + { + integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==, + } + engines: { node: '>= 4' } + + retry@0.13.1: + resolution: + { + integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==, + } + engines: { node: '>= 4' } + + rettime@0.11.11: + resolution: + { + integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==, + } + + reusify@1.0.4: + resolution: + { + integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==, + } + engines: { iojs: '>=1.0.0', node: '>=0.10.0' } + + rimraf@5.0.10: + resolution: + { + integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==, + } + hasBin: true + + rollup@4.63.4: + resolution: + { + integrity: sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==, + } + engines: { node: '>=18.0.0', npm: '>=8.0.0' } + hasBin: true + + router@2.2.0: + resolution: + { + integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==, + } + engines: { node: '>= 18' } + + run-parallel@1.2.0: + resolution: + { + integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==, + } + + sade@1.8.1: + resolution: + { + integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==, + } + engines: { node: '>=6' } + + safe-buffer@5.2.1: + resolution: + { + integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==, + } + + safer-buffer@2.1.2: + resolution: + { + integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==, + } + + seek-bzip@2.0.0: + resolution: + { + integrity: sha512-SMguiTnYrhpLdk3PwfzHeotrcwi8bNV4iemL9tx9poR/yeaMYwB9VzR1w7b57DuWpuqR8n6oZboi0hj3AxZxQg==, + } + hasBin: true + + semver@6.3.1: + resolution: + { + integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==, + } + hasBin: true + + semver@7.8.0: + resolution: + { + integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==, + } + engines: { node: '>=10' } + hasBin: true + + semver@7.8.5: + resolution: + { + integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, + } + engines: { node: '>=10' } + hasBin: true + + send@1.2.1: + resolution: + { + integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==, + } + engines: { node: '>= 18' } + + serve-static@2.2.1: + resolution: + { + integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==, + } + engines: { node: '>= 18' } + + set-cookie-parser@3.1.2: + resolution: + { + integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==, + } + + setprototypeof@1.2.0: + resolution: + { + integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==, + } + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: '>=8' } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: '>=8' } + + side-channel-list@1.0.1: + resolution: + { + integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==, + } + engines: { node: '>= 0.4' } + + side-channel-map@1.0.1: + resolution: + { + integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==, + } + engines: { node: '>= 0.4' } + + side-channel-weakmap@1.0.2: + resolution: + { + integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==, + } + engines: { node: '>= 0.4' } + + side-channel@1.1.1: + resolution: + { + integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==, + } + engines: { node: '>= 0.4' } + + sift@17.1.3: + resolution: + { + integrity: sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==, + } + + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } + + signal-exit@3.0.7: + resolution: + { + integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==, + } + + signal-exit@4.1.0: + resolution: + { + integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==, + } + engines: { node: '>=14' } + + simple-concat@1.0.1: + resolution: + { + integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==, + } + + simple-get@4.0.1: + resolution: + { + integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==, + } + + sisteransi@1.0.5: + resolution: + { + integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==, + } + + skin-tone@2.0.0: + resolution: + { + integrity: sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==, + } + engines: { node: '>=8' } + + slash@3.0.0: + resolution: + { + integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==, + } + engines: { node: '>=8' } + + smol-toml@1.8.0: + resolution: + { + integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==, + } + engines: { node: '>= 18' } + + source-map-js@1.2.1: + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: '>=0.10.0' } + + source-map-support@0.5.13: + resolution: + { + integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==, + } + + source-map@0.6.1: + resolution: + { + integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, + } + engines: { node: '>=0.10.0' } + + sparse-bitfield@3.0.3: + resolution: + { + integrity: sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==, + } + + sprintf-js@1.1.3: + resolution: + { + integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==, + } + + sql.js@1.14.2: + resolution: + { + integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==, + } + + stack-utils@2.0.6: + resolution: + { + integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==, + } + engines: { node: '>=10' } + + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } + + standardwebhooks@1.1.1: + resolution: + { + integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==, + } + + statuses@2.0.2: + resolution: + { + integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==, + } + engines: { node: '>= 0.8' } + + std-env@4.2.0: + resolution: + { + integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==, + } + + streamx@2.28.1: + resolution: + { + integrity: sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==, + } + + strict-event-emitter@0.5.1: + resolution: + { + integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==, + } + + string-length@4.0.2: + resolution: + { + integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==, + } + engines: { node: '>=10' } + + string-to-stream@3.0.1: + resolution: + { + integrity: sha512-Hl092MV3USJuUCC6mfl9sPzGloA3K5VwdIeJjYIkXY/8K+mUvaeEabWJgArp+xXrsWxCajeT2pc4axbVhIZJyg==, + } + + string-width@4.2.3: + resolution: + { + integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==, + } + engines: { node: '>=8' } + + string-width@5.1.2: + resolution: + { + integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==, + } + engines: { node: '>=12' } + + string_decoder@1.3.0: + resolution: + { + integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==, + } + + strip-ansi@6.0.1: + resolution: + { + integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==, + } + engines: { node: '>=8' } + + strip-ansi@7.2.0: + resolution: + { + integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==, + } + engines: { node: '>=12' } + + strip-bom@3.0.0: + resolution: + { + integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==, + } + engines: { node: '>=4' } + + strip-bom@4.0.0: + resolution: + { + integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==, + } + engines: { node: '>=8' } + + strip-final-newline@2.0.0: + resolution: + { + integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==, + } + engines: { node: '>=6' } + + strip-json-comments@2.0.1: + resolution: + { + integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==, + } + engines: { node: '>=0.10.0' } + + strip-json-comments@3.1.1: + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, + } + engines: { node: '>=8' } + + strnum@2.4.2: + resolution: + { + integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==, + } + + strtok3@10.3.5: + resolution: + { + integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==, + } + engines: { node: '>=18' } + + superstruct@1.0.4: + resolution: + { + integrity: sha512-7JpaAoX2NGyoFlI9NBh66BQXGONc+uE+MRS5i2iOBKuS4e+ccgMDjATgZldkah+33DakBxDHiss9kvUcGAO8UQ==, + } + engines: { node: '>=14.0.0' } + + supports-color@7.2.0: + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, + } + engines: { node: '>=8' } + + supports-color@8.1.1: + resolution: + { + integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==, + } + engines: { node: '>=10' } + + supports-hyperlinks@3.1.0: + resolution: + { + integrity: sha512-2rn0BZ+/f7puLOHZm1HOJfwBggfaHXUpPUSSG/SWM4TWp5KCfmNYwnC3hruy2rZlMnmWZ+QAGpZfchu3f3695A==, + } + engines: { node: '>=14.18' } + + supports-preserve-symlinks-flag@1.0.0: + resolution: + { + integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==, + } + engines: { node: '>= 0.4' } + + synckit@0.11.13: + resolution: + { + integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==, + } + engines: { node: ^14.18.0 || >=16.0.0 } + + systeminformation@5.33.13: + resolution: + { + integrity: sha512-GtyakkchTQKymM1WeAB652zi3rM1q00RKdc+jZe9KGstuc/mWUwAnQGN/RE5gVYvHIR8xYOUIUD9irVKDtA0wA==, + } + engines: { node: '>=10.0.0' } + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + + tagged-tag@1.0.0: + resolution: + { + integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==, + } + engines: { node: '>=20' } + + tar-fs@2.1.5: + resolution: + { + integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==, + } + + tar-stream@2.2.0: + resolution: + { + integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==, + } + engines: { node: '>=6' } + + tar-stream@3.1.7: + resolution: + { + integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==, + } + + test-exclude@6.0.0: + resolution: + { + integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==, + } + engines: { node: '>=8' } + + test-exclude@7.0.2: + resolution: + { + integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==, + } + engines: { node: '>=18' } + + text-decoder@1.2.7: + resolution: + { + integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==, + } + + thenify-all@1.6.0: + resolution: + { + integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==, + } + engines: { node: '>=0.8' } + + thenify@3.3.1: + resolution: + { + integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==, + } + + tinybench@2.9.0: + resolution: + { + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, + } + + tinyexec@1.3.1: + resolution: + { + integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==, + } + engines: { node: '>=18' } + + tinyglobby@0.2.17: + resolution: + { + integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, + } + engines: { node: '>=12.0.0' } + + tinyrainbow@3.1.1: + resolution: + { + integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==, + } + engines: { node: '>=14.0.0' } + + tldts-core@7.4.13: + resolution: + { + integrity: sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==, + } + + tldts@7.4.13: + resolution: + { + integrity: sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==, + } + hasBin: true + + tmpl@1.0.5: + resolution: + { + integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==, + } + + to-regex-range@5.0.1: + resolution: + { + integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==, + } + engines: { node: '>=8.0' } + + toidentifier@1.0.1: + resolution: + { + integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==, + } + engines: { node: '>=0.6' } + + token-types@6.1.2: + resolution: + { + integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==, + } + engines: { node: '>=14.16' } + + tough-cookie@6.0.2: + resolution: + { + integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==, + } + engines: { node: '>=16' } + + tr46@5.1.1: + resolution: + { + integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==, + } + engines: { node: '>=18' } + + ts-algebra@2.0.0: + resolution: + { + integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==, + } + + ts-api-utils@2.0.1: + resolution: + { + integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==, + } + engines: { node: '>=18.12' } + peerDependencies: + typescript: '>=4.8.4' + + ts-jest@29.4.6: + resolution: + { + integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==, + } + engines: { node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0 } + hasBin: true + peerDependencies: + '@babel/core': ^7.29.7 + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-node@10.7.0: + resolution: + { + integrity: sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==, + } + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz: + resolution: + { + integrity: sha512-LrjLRdfDnJ6UcZPSsxxY8QDnZmS3ZpPyvzgjUlNMjjRoTAUVqeL+IWrIzEU3Z+CwVrpVI97PePRLenEfCtR/UQ==, + tarball: https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz, + } + version: 1.1.11 + engines: { node: '>=14' } + hasBin: true + peerDependencies: + typescript: '>=4.3.0' + + tsconfig-paths@4.2.0: + resolution: + { + integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==, + } + engines: { node: '>=6' } + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + tunnel-agent@0.6.0: + resolution: + { + integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==, + } + + turndown@7.2.4: + resolution: + { + integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==, + } + engines: { node: '>=18', npm: '>=9' } + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: '>= 0.8.0' } + + type-detect@4.0.8: + resolution: + { + integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==, + } + engines: { node: '>=4' } + + type-fest@0.21.3: + resolution: + { + integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==, + } + engines: { node: '>=10' } + + type-fest@4.41.0: + resolution: + { + integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==, + } + engines: { node: '>=16' } + + type-fest@5.10.0: + resolution: + { + integrity: sha512-NoSdpq/WEiAg5sjmBkmV/hfxv6HJH4NqPNrqjtSO5CwRmpsDfaf4begxW34KdJykH/l1yHtwBWQkCRdoXO8mPA==, + } + engines: { node: '>=20' } + + type-is@2.1.0: + resolution: + { + integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==, + } + engines: { node: '>= 18' } + + typebox@1.1.38: + resolution: + { + integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==, + } + + typebox@1.3.34: + resolution: + { + integrity: sha512-wbnzrXXDW8xEFHDZZs2jo1MkhaYlKAY4FRhpBc1+2LF1fZVBGCXGdLEhA/Z/NBbgzJMFfeM8m7elKPa/+KxaUQ==, + } + + typedoc-plugin-expand-object-like-types@0.1.2: + resolution: + { + integrity: sha512-RRMOCWMElQHBOVraWMWrh/0tDqCdS5oxYwaWMZBB3KlUUUUCxKllpvJPsRH/uFLO1nOuy28CbJxGVU1umv7LOQ==, + } + peerDependencies: + typedoc: 0.22.x || 0.23.x + + typedoc@0.28.20: + resolution: + { + integrity: sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==, + } + engines: { node: '>= 18', pnpm: '>= 10' } + hasBin: true + peerDependencies: + typescript: 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x + + typescript-eslint@8.31.1: + resolution: + { + integrity: sha512-j6DsEotD/fH39qKzXTQRwYYWlt7D+0HmfpOK+DVhwJOFLcdmn92hq3mBb7HlKJHbjjI/gTOqEcc9d6JfpFf/VA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.9.0' + + typescript@5.6.1-rc: + resolution: + { + integrity: sha512-E3b2+1zEFu84jB0YQi9BORDjz9+jGbwwy1Zi3G0LUNw7a7cePUrHMRNy8aPh53nXpkFGVHSxIZo5vKTfYaFiBQ==, + } + engines: { node: '>=14.17' } + hasBin: true + + typescript@5.8.3: + resolution: + { + integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==, + } + engines: { node: '>=14.17' } + hasBin: true + + uc.micro@2.1.0: + resolution: + { + integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==, + } + + uglify-js@3.19.3: + resolution: + { + integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==, + } + engines: { node: '>=0.8.0' } + hasBin: true + + uint8array-extras@1.5.0: + resolution: + { + integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==, + } + engines: { node: '>=18' } + + undici-types@7.24.6: + resolution: + { + integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==, + } + + undici@7.29.1: + resolution: + { + integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==, + } + engines: { node: '>=20.18.1' } + + undici@8.10.2: + resolution: + { + integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, + } + engines: { node: '>=22.19.0' } + + unicode-emoji-modifier-base@1.0.0: + resolution: + { + integrity: sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==, + } + engines: { node: '>=4' } + + unpipe@1.0.0: + resolution: + { + integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==, + } + engines: { node: '>= 0.8' } + + until-async@3.0.2: + resolution: + { + integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==, + } + + update-browserslist-db@1.3.3: + resolution: + { + integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==, + } + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } + + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + + uuid@11.1.1: + resolution: + { + integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==, + } + hasBin: true + + v8-compile-cache-lib@3.0.0: + resolution: + { + integrity: sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==, + } + + v8-to-istanbul@9.2.0: + resolution: + { + integrity: sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==, + } + engines: { node: '>=10.12.0' } + + validate-npm-package-name@5.0.1: + resolution: + { + integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==, + } + engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + + vary@1.1.2: + resolution: + { + integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==, + } + engines: { node: '>= 0.8' } + + vite@7.3.6: + resolution: + { + integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: + { + integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==, + } + engines: { node: ^20.0.0 || ^22.0.0 || >=24.0.0 } + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^7.3.2 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + walker@1.0.8: + resolution: + { + integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==, + } + + web-streams-polyfill@3.3.3: + resolution: + { + integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==, + } + engines: { node: '>= 8' } + + webidl-conversions@7.0.0: + resolution: + { + integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==, + } + engines: { node: '>=12' } + + whatwg-url@14.2.0: + resolution: + { + integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==, + } + engines: { node: '>=18' } + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: '>= 8' } + hasBin: true + + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: '>=8' } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: '>=0.10.0' } + + wordwrap@1.0.0: + resolution: + { + integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==, + } + + wrap-ansi@7.0.0: + resolution: + { + integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==, + } + engines: { node: '>=10' } + + wrap-ansi@8.1.0: + resolution: + { + integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==, + } + engines: { node: '>=12' } + + wrappy@1.0.2: + resolution: + { + integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==, + } + + write-file-atomic@4.0.2: + resolution: + { + integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==, + } + engines: { node: ^12.13.0 || ^14.15.0 || >=16.0.0 } + + write-file-atomic@5.0.1: + resolution: + { + integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==, + } + engines: { node: ^14.17.0 || ^16.13.0 || >=18.0.0 } + + ws@8.21.3: + resolution: + { + integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==, + } + engines: { node: '>=10.0.0' } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xdg-app-paths@5.1.0: + resolution: + { + integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==, + } + engines: { node: '>=6' } + + xdg-portable@7.3.0: + resolution: + { + integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==, + } + engines: { node: '>= 6.0' } + + xml-naming@0.3.0: + resolution: + { + integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==, + } + engines: { node: '>=16.0.0' } + + xtend@4.0.2: + resolution: + { + integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==, + } + engines: { node: '>=0.4' } + + y18n@5.0.8: + resolution: + { + integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==, + } + engines: { node: '>=10' } + + yallist@3.1.1: + resolution: + { + integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==, + } + + yaml@2.9.0: + resolution: + { + integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==, + } + engines: { node: '>= 14.6' } + hasBin: true + + yaml@2.9.1: + resolution: + { + integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==, + } + engines: { node: '>= 14.6' } + hasBin: true + + yargs-parser@20.2.9: + resolution: + { + integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==, + } + engines: { node: '>=10' } + + yargs-parser@21.1.1: + resolution: + { + integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==, + } + engines: { node: '>=12' } + + yargs@16.2.0: + resolution: + { + integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==, + } + engines: { node: '>=10' } + + yargs@17.7.2: + resolution: + { + integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==, + } + engines: { node: '>=12' } + + yn@3.1.1: + resolution: + { + integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==, + } + engines: { node: '>=6' } + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: '>=10' } + + zod-to-json-schema@3.25.2: + resolution: + { + integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==, + } + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: + { + integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==, + } + + zod@4.6.5: + resolution: + { + integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==, + } + +snapshots: + '@ai-sdk/anthropic@4.0.18(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + zod: 4.6.5 + + '@ai-sdk/gateway@4.0.27(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + '@vercel/oidc': 3.2.0 + zod: 4.6.5 + + '@ai-sdk/harness-claude-code@1.0.42(zod@4.6.5)': + dependencies: + '@ai-sdk/harness': 1.0.41(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + ws: 8.21.3 + zod: 4.6.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@ai-sdk/harness-pi@1.0.41(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@ai-sdk/harness': 1.0.41(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + '@earendil-works/pi-coding-agent': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + typebox: 1.3.34 + zod: 4.6.5 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + + '@ai-sdk/harness@1.0.40(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + ai: 7.0.35(zod@4.6.5) + zod: 4.6.5 + optionalDependencies: + ws: 8.21.3 + + '@ai-sdk/harness@1.0.41(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + ai: 7.0.36(zod@4.6.5) + zod: 4.6.5 + optionalDependencies: + ws: 8.21.3 + + '@ai-sdk/openai@4.0.4(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.1 + '@ai-sdk/provider-utils': 5.0.2(zod@4.6.5) + zod: 4.6.5 + + '@ai-sdk/provider-utils@5.0.12(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.3 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + zod: 4.6.5 + + '@ai-sdk/provider-utils@5.0.2(zod@4.6.5)': + dependencies: + '@ai-sdk/provider': 4.0.1 + '@standard-schema/spec': 1.1.0 + '@workflow/serde': 4.1.0 + eventsource-parser: 3.1.1 + zod: 4.6.5 + + '@ai-sdk/provider@4.0.1': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/provider@4.0.3': + dependencies: + json-schema: 0.4.0 + + '@ai-sdk/sandbox-just-bash@1.0.40(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@ai-sdk/harness': 1.0.40(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + just-bash: 2.14.5 + transitivePeerDependencies: + - supports-color + - ws + - zod + + '@ai-sdk/sandbox-vercel@1.0.41(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@ai-sdk/harness': 1.0.41(ws@8.21.3)(zod@4.6.5) + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + '@vercel/sandbox': 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + - ws + - zod + + '@andrewbranch/untar.js@1.0.3': {} + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.278': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.278(@anthropic-ai/sdk@0.98.1(zod@4.6.5))(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(zod@4.6.5)': + dependencies: + '@anthropic-ai/sdk': 0.98.1(zod@4.6.5) + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + zod: 4.6.5 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.278 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.278 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.278 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.278 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.278 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.278 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.278 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.278 + + '@anthropic-ai/sdk@0.91.1(zod@4.6.5)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.6.5 + + '@anthropic-ai/sdk@0.98.1(zod@4.6.5)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + optionalDependencies: + zod: 4.6.5 + + '@arethetypeswrong/cli@0.18.5': + dependencies: + '@arethetypeswrong/core': 0.18.5 + chalk: 4.1.2 + cli-table3: 0.6.5 + commander: 10.0.1 + marked: 9.1.6 + marked-terminal: 7.2.1(marked@9.1.6) + semver: 7.8.5 + + '@arethetypeswrong/core@0.18.5': + dependencies: + '@andrewbranch/untar.js': 1.0.3 + '@loaderkit/resolve': 1.0.6 + cjs-module-lexer: 1.4.1 + fflate: 0.8.3 + lru-cache: 11.5.2 + semver: 7.8.5 + typescript: 5.6.1-rc + validate-npm-package-name: 5.0.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.5 + '@aws-sdk/util-locate-window': 3.965.10 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.974.5 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-node': 3.972.83 + '@aws-sdk/eventstream-handler-node': 3.972.34 + '@aws-sdk/middleware-eventstream': 3.972.29 + '@aws-sdk/middleware-websocket': 3.972.53 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.0': + dependencies: + '@aws-sdk/types': 3.974.5 + '@aws-sdk/xml-builder': 3.972.40 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.34.1 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.71': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.73': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-login': 3.972.78 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.83': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.71 + '@aws-sdk/credential-provider-http': 3.972.73 + '@aws-sdk/credential-provider-ini': 3.973.16 + '@aws-sdk/credential-provider-process': 3.972.71 + '@aws-sdk/credential-provider-sso': 3.973.15 + '@aws-sdk/credential-provider-web-identity': 3.972.77 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.71': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.15': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/token-providers': 3.1129.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.77': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.34': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.29': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.53': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.45': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/signature-v4-multi-region': 3.996.46 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.46': + dependencies: + '@aws-sdk/types': 3.974.5 + '@smithy/signature-v4': 5.7.3 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1129.0': + dependencies: + '@aws-sdk/core': 3.978.0 + '@aws-sdk/nested-clients': 3.997.45 + '@aws-sdk/types': 3.974.5 + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.5': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.10': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.40': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/code-frame@7.28.6': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.6': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.28.6 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-attributes@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} + + '@borewit/text-codec@0.2.2': {} + + '@braidai/lang@1.1.2': {} + + '@cfworker/json-schema@4.1.1': {} + + '@colors/colors@1.5.0': + optional: true + + '@cspotcode/source-map-consumer@0.8.0': {} + + '@cspotcode/source-map-support@0.7.0': + dependencies: + '@cspotcode/source-map-consumer': 0.8.0 + + '@earendil-works/pi-agent-core@0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.6.5) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.3)(zod@4.6.5) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + '@earendil-works/pi-ai': 0.80.10(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))(ws@8.21.3)(zod@4.6.5) + '@earendil-works/pi-tui': 0.80.10 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 9.0.9 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.10.2 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.80.10': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.4.0(eslint@9.39.1(jiti@2.7.0))': + dependencies: + eslint: 9.39.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.7.0))': + dependencies: + eslint: 9.39.1(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.1': {} + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 9.0.9 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.3': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 9.0.9 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.1': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@faker-js/faker@10.6.0': {} + + '@gerrit0/mini-shiki@3.23.0': + dependencies: + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.21.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@google/genai@2.23.0(@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5))': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.21.3 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grpc/grpc-js@1.14.5': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.2 + + '@hono/node-server@2.0.10(hono@4.13.5)': + dependencies: + hono: 4.13.5 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@2.0.8': {} + + '@inquirer/confirm@6.3.2(@types/node@25.9.8)': + dependencies: + '@inquirer/core': 12.0.3(@types/node@25.9.8) + '@inquirer/type': 4.1.1(@types/node@25.9.8) + optionalDependencies: + '@types/node': 25.9.8 + + '@inquirer/core@12.0.3(@types/node@25.9.8)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@25.9.8) + cli-width: 4.1.0 + fast-wrap-ansi: 0.2.2 + mute-stream: 3.0.0 + signal-exit: 4.1.0 + optionalDependencies: + '@types/node': 25.9.8 + + '@inquirer/figures@2.0.9': {} + + '@inquirer/type@4.1.1(@types/node@25.9.8)': + optionalDependencies: + '@types/node': 25.9.8 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 4.3.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + chalk: 4.1.2 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + + '@jest/console@30.5.2': + dependencies: + '@jest/types': 30.5.1 + '@types/node': 25.9.8 + chalk: 4.1.2 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + slash: 3.0.0 + + '@jest/core@29.7.0(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3))': + dependencies: + '@jest/console': 29.7.0 + '@jest/reporters': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-changed-files: 29.7.0 + jest-config: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-resolve-dependencies: 29.7.0 + jest-runner: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + jest-watcher: 29.7.0 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-ansi: 6.0.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + - ts-node + + '@jest/create-cache-key-function@29.7.0': + dependencies: + '@jest/types': 29.6.3 + + '@jest/diff-sequences@30.5.0': {} + + '@jest/environment@29.7.0': + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + jest-mock: 29.7.0 + + '@jest/environment@30.5.2': + dependencies: + '@jest/fake-timers': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 25.9.8 + jest-mock: 30.5.2 + + '@jest/expect-utils@29.7.0': + dependencies: + jest-get-type: 29.6.3 + + '@jest/expect-utils@30.5.2': + dependencies: + '@jest/get-type': 30.5.0 + + '@jest/expect@29.7.0': + dependencies: + expect: 29.7.0 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/expect@30.5.2': + dependencies: + expect: 30.5.2 + jest-snapshot: 30.5.2 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@29.7.0': + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 25.9.8 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + '@jest/fake-timers@30.5.2': + dependencies: + '@jest/types': 30.5.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 25.9.8 + jest-message-util: 30.5.1 + jest-mock: 30.5.2 + jest-util: 30.5.1 + + '@jest/get-type@30.5.0': {} + + '@jest/globals@29.7.0': + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/types': 29.6.3 + jest-mock: 29.7.0 + transitivePeerDependencies: + - supports-color + + '@jest/globals@30.5.2': + dependencies: + '@jest/environment': 30.5.2 + '@jest/expect': 30.5.2 + '@jest/types': 30.5.1 + jest-mock: 30.5.2 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.5.0': + dependencies: + '@types/node': 25.9.8 + jest-regex-util: 30.5.0 + + '@jest/reporters@29.7.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.9.8 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit: 0.1.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.6 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + jest-worker: 29.7.0 + slash: 3.0.0 + string-length: 4.0.2 + strip-ansi: 6.0.1 + v8-to-istanbul: 9.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/reporters@30.5.2': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.5.2 + '@jest/test-result': 30.5.2 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 25.9.8 + chalk: 4.1.2 + collect-v8-coverage: 1.0.2 + exit-x: 0.2.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.6 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + jest-worker: 30.5.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@29.6.3': + dependencies: + '@sinclair/typebox': 0.27.8 + + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/snapshot-utils@30.5.1': + dependencies: + '@jest/types': 30.5.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@29.6.3': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@29.7.0': + dependencies: + '@jest/console': 29.7.0 + '@jest/types': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-result@30.5.2': + dependencies: + '@jest/console': 30.5.2 + '@jest/types': 30.5.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.2 + + '@jest/test-sequencer@29.7.0': + dependencies: + '@jest/test-result': 29.7.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + slash: 3.0.0 + + '@jest/transform@29.7.0': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + micromatch: 4.0.8 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + + '@jest/transform@30.5.2': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 8.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@29.6.3': + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.8 + '@types/yargs': 17.0.32 + chalk: 4.1.2 + + '@jest/types@30.5.1': + dependencies: + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 25.9.8 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jitl/quickjs-ffi-types@0.32.0': {} + + '@jitl/quickjs-wasmfile-debug-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-debug-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-asyncify@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jitl/quickjs-wasmfile-release-sync@0.32.0': + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)': + dependencies: + '@cfworker/json-schema': 4.1.1 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.21 + langsmith: 0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 11.1.1 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))': + dependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + uuid: 11.1.1 + + '@langchain/langgraph-sdk@0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))': + dependencies: + '@types/json-schema': 7.0.15 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 11.1.1 + optionalDependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + + '@langchain/langgraph@0.3.12(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(zod-to-json-schema@3.25.2(zod@4.6.5))': + dependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) + '@langchain/langgraph-sdk': 0.0.112(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) + uuid: 11.1.1 + zod: 3.25.76 + optionalDependencies: + zod-to-json-schema: 3.25.2(zod@4.6.5) + transitivePeerDependencies: + - react + - react-dom + + '@langchain/openai@0.6.17(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(ws@8.21.3)': + dependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + js-tiktoken: 1.0.21 + openai: 5.12.2(ws@8.21.3)(zod@3.25.76) + zod: 3.25.76 + transitivePeerDependencies: + - ws + + '@langchain/textsplitters@0.1.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))': + dependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + js-tiktoken: 1.0.21 + + '@loaderkit/resolve@1.0.6': + dependencies: + '@braidai/lang': 1.1.2 + + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.43.0 + ws: 8.21.3 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@mixmark-io/domino@2.2.0': {} + + '@modelcontextprotocol/sdk@1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5)': + dependencies: + '@hono/node-server': 2.0.10(hono@4.13.5) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + express: 5.2.1 + express-rate-limit: 8.7.0(express@5.2.1) + hono: 4.13.5 + jose: 6.2.12 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.6.5 + zod-to-json-schema: 3.25.2(zod@4.6.5) + optionalDependencies: + '@cfworker/json-schema': 4.1.1 + transitivePeerDependencies: + - supports-color + + '@mongodb-js/saslprep@1.5.4': + dependencies: + sparse-bitfield: 3.0.3 + + '@mongodb-js/zstd@7.0.0': + dependencies: + node-addon-api: 8.9.2 + prebuild-install: 7.1.3 + optional: true + + '@mswjs/interceptors@0.41.9': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@nodable/entities@3.0.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@openai/agents-core@0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5)': + dependencies: + debug: 4.4.3 + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + optionalDependencies: + '@modelcontextprotocol/sdk': 1.30.0(@cfworker/json-schema@4.1.1)(zod@4.6.5) + zod: 4.6.5 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - supports-color + - ws + + '@openai/agents-openai@0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@openai/agents-core': 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + debug: 4.4.3 + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + zod: 4.6.5 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - supports-color + - ws + + '@openai/agents-realtime@0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(zod@4.6.5)': + dependencies: + '@openai/agents-core': 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + '@types/ws': 8.18.1 + debug: 4.4.3 + ws: 8.21.3 + zod: 4.6.5 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - supports-color + - utf-8-validate + + '@openai/agents@0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5)': + dependencies: + '@openai/agents-core': 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + '@openai/agents-openai': 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + '@openai/agents-realtime': 0.12.1(@aws-sdk/credential-provider-node@3.972.83)(@cfworker/json-schema@4.1.1)(@smithy/signature-v4@5.7.3)(zod@4.6.5) + debug: 4.4.3 + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + zod: 4.6.5 + transitivePeerDependencies: + - '@aws-sdk/credential-provider-node' + - '@cfworker/json-schema' + - '@smithy/hash-node' + - '@smithy/signature-v4' + - bufferutil + - supports-color + - utf-8-validate + - ws + + '@opentelemetry/api-logs@0.222.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.80.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.77.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.39.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-host-metrics': 0.5.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.75.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.20.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.47.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.68.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.70.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.35.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.69.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.41.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.37.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.30.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.13(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + yaml: 2.9.1 + + '@opentelemetry/context-async-hooks@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-amqplib@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-aws-xray': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/aws-lambda': 8.10.163 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.77.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.39.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-host-metrics@0.5.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + systeminformation: 5.33.13 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.75.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.20.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.47.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.68.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.70.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.35.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-socket.io@0.69.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.41.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.32.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-winston@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + import-in-the-middle: 3.5.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/otlp-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-grpc-exporter-base@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.5 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.222.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/otlp-transformer@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-aws-xray@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/propagator-b3@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/propagator-jaeger@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/redis-common@0.38.3': {} + + '@opentelemetry/resource-detector-alibaba-cloud@0.37.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-aws@2.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resource-detector-azure@0.30.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resource-detector-container@0.8.13(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/resource-detector-gcp@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.4 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.222.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.222.0 + '@opentelemetry/configuration': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-trace-node@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-trace@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.7 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@rollup/rollup-android-arm-eabi@4.63.4': + optional: true + + '@rollup/rollup-android-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.4': + optional: true + + '@rollup/rollup-darwin-x64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.4': + optional: true + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@sinclair/typebox@0.27.8': {} + + '@sinclair/typebox@0.34.52': {} + + '@sindresorhus/is@4.6.0': {} + + '@sinonjs/commons@3.0.0': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@10.3.0': + dependencies: + '@sinonjs/commons': 3.0.0 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@smithy/core@3.34.1': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.3': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + + '@smithy/types@4.18.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@stablelib/base64@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/core-darwin-arm64@1.4.16': + optional: true + + '@swc/core-darwin-x64@1.4.16': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.4.16': + optional: true + + '@swc/core-linux-arm64-gnu@1.4.16': + optional: true + + '@swc/core-linux-arm64-musl@1.4.16': + optional: true + + '@swc/core-linux-x64-gnu@1.4.16': + optional: true + + '@swc/core-linux-x64-musl@1.4.16': + optional: true + + '@swc/core-win32-arm64-msvc@1.4.16': + optional: true + + '@swc/core-win32-ia32-msvc@1.4.16': + optional: true + + '@swc/core-win32-x64-msvc@1.4.16': + optional: true + + '@swc/core@1.4.16': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.6 + optionalDependencies: + '@swc/core-darwin-arm64': 1.4.16 + '@swc/core-darwin-x64': 1.4.16 + '@swc/core-linux-arm-gnueabihf': 1.4.16 + '@swc/core-linux-arm64-gnu': 1.4.16 + '@swc/core-linux-arm64-musl': 1.4.16 + '@swc/core-linux-x64-gnu': 1.4.16 + '@swc/core-linux-x64-musl': 1.4.16 + '@swc/core-win32-arm64-msvc': 1.4.16 + '@swc/core-win32-ia32-msvc': 1.4.16 + '@swc/core-win32-x64-msvc': 1.4.16 + + '@swc/counter@0.1.3': {} + + '@swc/jest@0.2.36(@swc/core@1.4.16)': + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@swc/core': 1.4.16 + '@swc/counter': 0.1.3 + jsonc-parser: 3.2.1 + + '@swc/types@0.1.6': + dependencies: + '@swc/counter': 0.1.3 + + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3 + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + + '@tokenizer/token@0.3.0': {} + + '@tsconfig/node10@1.0.8': {} + + '@tsconfig/node12@1.0.9': {} + + '@tsconfig/node14@1.0.1': {} + + '@tsconfig/node16@1.0.2': {} + + '@types/aws-lambda@8.10.163': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.4 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.28.6 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.6 + '@babel/types': 7.28.6 + + '@types/babel__traverse@7.20.4': + dependencies: + '@babel/types': 7.28.6 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.9.8 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/graceful-fs@4.1.9': + dependencies: + '@types/node': 25.9.8 + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@29.5.11': + dependencies: + expect: 29.7.0 + pretty-format: 29.7.0 + + '@types/json-schema@7.0.15': {} + + '@types/memcached@2.2.10': + dependencies: + '@types/node': 25.9.8 + + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.9.8 + form-data: 4.0.6 + + '@types/node@25.9.8': + dependencies: + undici-types: 7.24.6 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 25.9.8 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + + '@types/retry@0.12.0': {} + + '@types/semver@7.8.0': {} + + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 25.9.8 + + '@types/stack-utils@2.0.3': {} + + '@types/statuses@2.0.6': {} + + '@types/unist@3.0.3': {} + + '@types/webidl-conversions@7.0.3': {} + + '@types/whatwg-url@13.0.0': + dependencies: + '@types/webidl-conversions': 7.0.3 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.8 + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.32': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@eslint-community/regexpp': 4.12.1 + '@typescript-eslint/parser': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.31.1 + '@typescript-eslint/type-utils': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/utils': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.31.1 + eslint: 9.39.1(jiti@2.7.0) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 2.0.1(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.31.1 + '@typescript-eslint/types': 8.31.1 + '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.31.1 + debug: 4.4.1 + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.31.1': + dependencies: + '@typescript-eslint/types': 8.31.1 + '@typescript-eslint/visitor-keys': 8.31.1 + + '@typescript-eslint/type-utils@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + debug: 4.4.3 + eslint: 9.39.1(jiti@2.7.0) + ts-api-utils: 2.0.1(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.31.1': {} + + '@typescript-eslint/typescript-estree@8.31.1(typescript@5.8.3)': + dependencies: + '@typescript-eslint/types': 8.31.1 + '@typescript-eslint/visitor-keys': 8.31.1 + debug: 4.4.3 + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.9 + semver: 7.8.5 + ts-api-utils: 2.0.1(typescript@5.8.3) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@9.39.1(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.31.1 + '@typescript-eslint/types': 8.31.1 + '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3) + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.31.1': + dependencies: + '@typescript-eslint/types': 8.31.1 + eslint-visitor-keys: 4.2.0 + + '@ungap/structured-clone@1.4.0': {} + + '@vercel/oidc@3.2.0': {} + + '@vercel/sandbox@2.9.2': + dependencies: + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jose: 6.2.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.29.1 + xdg-app-paths: 5.1.0 + zod: 4.6.5 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(msw@2.15.0(@types/node@25.9.8)(typescript@5.8.3))(vite@7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.15.0(@types/node@25.9.8)(typescript@5.8.3) + vite: 7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + '@workflow/serde@4.1.0': {} + + '@workflow/serde@4.1.0-beta.2': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.1.0 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn-walk@8.2.0: {} + + acorn@8.14.0: {} + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ai@7.0.35(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.27(zod@4.6.5) + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + zod: 4.6.5 + + ai@7.0.36(zod@4.6.5): + dependencies: + '@ai-sdk/gateway': 4.0.27(zod@4.6.5) + '@ai-sdk/provider': 4.0.3 + '@ai-sdk/provider-utils': 5.0.12(zod@4.6.5) + zod: 4.6.5 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.8 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-escapes@7.0.0: + dependencies: + environment: 1.1.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.1.0: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + anynum@1.0.1: {} + + arg@4.1.3: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + asynckit@0.4.0: {} + + b4a@1.9.0: {} + + babel-jest@29.7.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 29.7.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 29.6.3(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@6.1.1: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@8.0.0: + dependencies: + '@babel/helper-plugin-utils': 7.28.6 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 7.0.2 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@29.6.3: + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.4 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@29.6.3(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + bare-events@2.9.2: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.11.24: {} + + bignumber.js@9.3.1: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.1.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + bowser@2.14.1: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.29.0: + dependencies: + baseline-browser-mapping: 2.11.24 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.429 + node-releases: 2.0.55 + update-browserslist-db: 1.3.3(browserslist@4.29.0) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + bson@7.3.3: {} + + buffer-equal-constant-time@1.0.1: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001810: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.3.0: {} + + chalk@5.6.2: {} + + char-regex@1.0.2: {} + + chownr@1.1.4: + optional: true + + ci-info@3.9.0: {} + + ci-info@4.4.0: {} + + cjs-module-lexer@1.4.1: {} + + cjs-module-lexer@2.2.1: {} + + clean-stack@2.2.0: {} + + cli-highlight@2.1.11: + dependencies: + chalk: 4.1.2 + highlight.js: 10.7.3 + mz: 2.7.0 + parse5: 5.1.1 + parse5-htmlparser2-tree-adapter: 6.0.1 + yargs: 16.2.0 + + cli-table3@0.6.5: + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + + cli-width@4.1.0: {} + + cliui@7.0.4: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@10.0.1: {} + + commander@6.2.1: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.1.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + create-jest@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decamelize@1.2.0: {} + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + optional: true + + dedent@1.5.1: {} + + deep-extend@0.6.0: + optional: true + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + detect-newline@3.1.0: {} + + diff-sequences@29.6.3: {} + + diff@4.0.4: {} + + diff@8.0.4: {} + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.429: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + emojilib@2.4.0: {} + + encodeurl@2.0.0: {} + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + optional: true + + entities@4.5.0: {} + + environment@1.1.0: {} + + error-ex@1.3.2: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-module-lexer@3.0.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.1.1: {} + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@2.0.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-unused-imports@4.1.4(@typescript-eslint/eslint-plugin@8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0)): + dependencies: + eslint: 9.39.1(jiti@2.7.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.0: {} + + eslint-visitor-keys@4.2.1: {} + + eslint@9.39.1(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.3 + '@eslint/js': 9.39.1 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 9.0.9 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.6.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventemitter3@4.0.7: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.9.2 + transitivePeerDependencies: + - bare-abort-controller + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + exit@0.1.2: {} + + expand-template@2.0.3: + optional: true + + expect-type@1.4.0: {} + + expect@29.7.0: + dependencies: + '@jest/expect-utils': 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + + expect@30.5.2: + dependencies: + '@jest/expect-utils': 30.5.2 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.2 + jest-message-util: 30.5.1 + jest-mock: 30.5.2 + jest-util: 30.5.1 + + express-rate-limit@8.7.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.7.1 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.8 + qs: 6.16.0 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-glob@3.3.2: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-sha256@1.3.0: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-uri@3.1.8: {} + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fast-xml-builder@1.3.1: + dependencies: + path-expression-matcher: 1.6.2 + xml-naming: 0.3.0 + + fast-xml-parser@5.11.1: + dependencies: + '@nodable/entities': 3.0.0 + fast-xml-builder: 1.3.1 + is-unsafe: 2.0.2 + path-expression-matcher: 1.6.2 + strnum: 2.4.2 + xml-naming: 0.3.0 + + fastq@1.17.1: + dependencies: + reusify: 1.0.4 + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + fflate@0.8.3: {} + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + file-type@21.3.4: + dependencies: + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-constants@1.0.0: + optional: true + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.4: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-package-type@0.1.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stdin@8.0.0: {} + + get-stream@6.0.1: {} + + github-from-package@0.0.0: + optional: true + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 9.0.9 + minipass: 7.1.3 + path-scurry: 2.0.2 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 9.0.9 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 9.0.9 + once: 1.4.0 + + globals@14.0.0: {} + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphemer@1.4.0: {} + + graphql@16.14.2: {} + + handlebars@4.7.9: + dependencies: + minimist: 1.2.6 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.0: + dependencies: + function-bind: 1.1.2 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + + highlight.js@10.7.3: {} + + hono@4.13.5: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.2 + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore-walk@5.0.1: + dependencies: + minimatch: 9.0.9 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + import-in-the-middle@3.5.1: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 3.0.2 + module-details-from-path: 1.0.4 + + import-local@3.1.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ini@1.3.8: + optional: true + + ini@6.0.0: {} + + ip-address@10.7.1: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-core-module@2.13.1: + dependencies: + hasown: 2.0.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + + is-promise@4.0.0: {} + + is-stream@2.0.1: {} + + is-unsafe@2.0.2: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@5.2.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@4.0.1: + dependencies: + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.1.6: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@29.7.0: + dependencies: + execa: 5.1.1 + jest-util: 29.7.0 + p-limit: 3.1.0 + + jest-circus@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/expect': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.5.1 + is-generator-fn: 2.1.0 + jest-each: 29.7.0 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-runtime: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + p-limit: 3.1.0 + pretty-format: 29.7.0 + pure-rand: 6.0.4 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + exit: 0.1.2 + import-local: 3.1.0 + jest-config: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jest-config@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)): + dependencies: + '@babel/core': 7.29.7 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.9.8 + ts-node: 10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@29.7.0: + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-diff@30.5.2: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + + jest-docblock@29.7.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@29.7.0: + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + jest-get-type: 29.6.3 + jest-util: 29.7.0 + pretty-format: 29.7.0 + + jest-environment-node@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + jest-mock: 29.7.0 + jest-util: 29.7.0 + + jest-get-type@29.6.3: {} + + jest-haste-map@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.9 + '@types/node': 25.9.8 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.6.3 + jest-util: 29.7.0 + jest-worker: 29.7.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-haste-map@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@parcel/watcher': 2.6.0 + '@types/node': 25.9.8 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + fdir: 6.5.0(picomatch@4.0.7) + graceful-fs: 4.2.11 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + jest-worker: 30.5.1 + picomatch: 4.0.7 + + jest-leak-detector@29.7.0: + dependencies: + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@29.7.0: + dependencies: + chalk: 4.1.2 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + + jest-matcher-utils@30.5.2: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.2 + pretty-format: 30.5.1 + + jest-message-util@29.7.0: + dependencies: + '@babel/code-frame': 7.28.6 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-message-util@30.5.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.5.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.5.1 + picomatch: 4.0.7 + pretty-format: 30.5.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + jest-util: 29.7.0 + + jest-mock@30.5.2: + dependencies: + '@jest/expect-utils': 30.5.2 + '@jest/types': 30.5.1 + '@types/node': 25.9.8 + jest-util: 30.5.1 + + jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): + optionalDependencies: + jest-resolve: 29.7.0 + + jest-regex-util@29.6.3: {} + + jest-regex-util@30.5.0: {} + + jest-resolve-dependencies@29.7.0: + dependencies: + jest-regex-util: 29.6.3 + jest-snapshot: 29.7.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@29.7.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-pnp-resolver: 1.2.3(jest-resolve@29.7.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + resolve: 1.22.8 + resolve.exports: 2.0.2 + slash: 3.0.0 + + jest-runner@29.7.0: + dependencies: + '@jest/console': 29.7.0 + '@jest/environment': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + chalk: 4.1.2 + emittery: 0.13.1 + graceful-fs: 4.2.11 + jest-docblock: 29.7.0 + jest-environment-node: 29.7.0 + jest-haste-map: 29.7.0 + jest-leak-detector: 29.7.0 + jest-message-util: 29.7.0 + jest-resolve: 29.7.0 + jest-runtime: 29.7.0 + jest-util: 29.7.0 + jest-watcher: 29.7.0 + jest-worker: 29.7.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@29.7.0: + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/globals': 29.7.0 + '@jest/source-map': 29.6.3 + '@jest/test-result': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + chalk: 4.1.2 + cjs-module-lexer: 1.4.1 + collect-v8-coverage: 1.0.2 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-haste-map: 29.7.0 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-snapshot: 29.7.0 + jest-util: 29.7.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@29.7.0: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.28.6 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/types': 7.28.6 + '@jest/expect-utils': 29.7.0 + '@jest/transform': 29.7.0 + '@jest/types': 29.6.3 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 29.7.0 + graceful-fs: 4.2.11 + jest-diff: 29.7.0 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.7.0 + jest-message-util: 29.7.0 + jest-util: 29.7.0 + natural-compare: 1.4.0 + pretty-format: 29.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.5.2: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 30.5.2 + '@jest/get-type': 30.5.0 + '@jest/snapshot-utils': 30.5.1 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.5.2 + graceful-fs: 4.2.11 + jest-diff: 30.5.2 + jest-matcher-utils: 30.5.2 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + pretty-format: 30.5.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + + jest-util@29.7.0: + dependencies: + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.2 + + jest-util@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@types/node': 25.9.8 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.7 + + jest-validate@29.7.0: + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + jest-watcher@29.7.0: + dependencies: + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 25.9.8 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 29.7.0 + string-length: 4.0.2 + + jest-worker@29.7.0: + dependencies: + '@types/node': 25.9.8 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest-worker@30.5.1: + dependencies: + '@types/node': 25.9.8 + '@ungap/structured-clone': 1.4.0 + jest-util: 30.5.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)): + dependencies: + '@jest/core': 29.7.0(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + '@jest/types': 29.6.3 + import-local: 3.1.0 + jest-cli: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + + jiti@2.7.0: {} + + jose@6.2.12: {} + + jose@6.2.3: {} + + js-tiktoken@1.0.21: + dependencies: + base64-js: 1.5.1 + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-schema@0.4.0: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonc-parser@3.2.1: {} + + jsonlines@0.1.1: {} + + jsonpointer@5.0.1: {} + + just-bash@2.14.5: + dependencies: + diff: 8.0.4 + fast-xml-parser: 5.11.1 + file-type: 21.3.4 + ini: 6.0.0 + minimatch: 9.0.9 + modern-tar: 0.7.7 + papaparse: 5.7.0 + quickjs-emscripten: 0.32.0 + re2js: 1.3.3 + seek-bzip: 2.0.0 + smol-toml: 1.8.0 + sprintf-js: 1.1.3 + sql.js: 1.14.2 + turndown: 7.2.4 + yaml: 2.9.1 + optionalDependencies: + '@mongodb-js/zstd': 7.0.0 + node-liblzma: 2.2.0 + transitivePeerDependencies: + - supports-color + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + kareem@3.4.0: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + langchain@0.3.37(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(handlebars@4.7.9)(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3): + dependencies: + '@langchain/core': 0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + '@langchain/openai': 0.6.17(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3))(ws@8.21.3) + '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.80(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3)) + js-tiktoken: 1.0.21 + js-yaml: 4.3.2 + jsonpointer: 5.0.1 + langsmith: 0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3) + openapi-types: 12.1.3 + p-retry: 4.6.2 + uuid: 11.1.1 + yaml: 2.9.1 + zod: 3.25.76 + optionalDependencies: + handlebars: 4.7.9 + transitivePeerDependencies: + - '@opentelemetry/api' + - '@opentelemetry/exporter-trace-otlp-proto' + - '@opentelemetry/sdk-trace-base' + - openai + - ws + + langsmith@0.6.0(@opentelemetry/api@1.9.1)(@opentelemetry/exporter-trace-otlp-proto@0.222.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.11.0(@opentelemetry/api@1.9.1))(openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5))(ws@8.21.3): + dependencies: + p-queue: 6.6.2 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/exporter-trace-otlp-proto': 0.222.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.11.0(@opentelemetry/api@1.9.1) + openai: 6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5) + ws: 8.21.3 + + leven@3.1.0: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lines-and-columns@1.2.4: {} + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.camelcase@4.3.0: {} + + lodash.memoize@4.1.2: {} + + lodash.merge@4.6.2: {} + + long@5.3.2: {} + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lunr@2.3.9: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + markdown-it@14.3.2: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.1.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + marked-terminal@7.2.1(marked@9.1.6): + dependencies: + ansi-escapes: 7.0.0 + ansi-regex: 6.1.0 + chalk: 5.3.0 + cli-highlight: 2.1.11 + cli-table3: 0.6.5 + marked: 9.1.6 + node-emoji: 2.1.3 + supports-hyperlinks: 3.1.0 + + marked@18.0.5: {} + + marked@9.1.6: {} + + math-intrinsics@1.1.0: {} + + mdurl@2.1.0: {} + + media-typer@1.1.1: {} + + memory-pager@1.5.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-response@3.1.0: + optional: true + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minimist@1.2.6: {} + + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: + optional: true + + modern-tar@0.7.7: {} + + module-details-from-path@1.0.4: {} + + mongodb-connection-string-url@7.0.2: + dependencies: + '@types/whatwg-url': 13.0.0 + whatwg-url: 14.2.0 + + mongodb@7.6.0(@mongodb-js/zstd@7.0.0): + dependencies: + '@mongodb-js/saslprep': 1.5.4 + bson: 7.3.3 + mongodb-connection-string-url: 7.0.2 + optionalDependencies: + '@mongodb-js/zstd': 7.0.0 + + mongoose@9.10.1(@mongodb-js/zstd@7.0.0): + dependencies: + '@standard-schema/spec': 1.1.0 + kareem: 3.4.0 + mongodb: 7.6.0(@mongodb-js/zstd@7.0.0) + mpath: 0.9.0 + mquery: 6.0.0 + ms: 2.1.3 + sift: 17.1.3 + transitivePeerDependencies: + - '@aws-sdk/credential-providers' + - '@mongodb-js/zstd' + - gcp-metadata + - kerberos + - mongodb-client-encryption + - snappy + - socks + + mpath@0.9.0: {} + + mquery@6.0.0: {} + + mri@1.2.0: {} + + ms@2.1.3: {} + + msw@2.15.0(@types/node@25.9.8)(typescript@5.8.3): + dependencies: + '@inquirer/confirm': 6.3.2(@types/node@25.9.8) + '@mswjs/interceptors': 0.41.9 + '@open-draft/deferred-promise': 3.0.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.14.2 + headers-polyfill: 5.0.1 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.11.11 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.2 + type-fest: 5.10.0 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.8.3 + transitivePeerDependencies: + - '@types/node' + + mustache@4.2.0: {} + + mute-stream@3.0.0: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.19: {} + + napi-build-utils@2.0.0: + optional: true + + natural-compare@1.4.0: {} + + negotiator@1.1.0: + dependencies: + content-type: 2.1.0 + + neo-async@2.6.2: {} + + node-abi@3.96.0: + dependencies: + semver: 7.8.5 + optional: true + + node-addon-api@7.1.1: {} + + node-addon-api@8.9.2: + optional: true + + node-domexception@1.0.0: {} + + node-emoji@2.1.3: + dependencies: + '@sindresorhus/is': 4.6.0 + char-regex: 1.0.2 + emojilib: 2.4.0 + skin-tone: 2.0.0 + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-gyp-build@4.8.4: + optional: true + + node-int64@0.4.0: {} + + node-liblzma@2.2.0: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + optional: true + + node-releases@2.0.55: {} + + normalize-path@3.0.0: {} + + npm-bundled@2.0.1: + dependencies: + npm-normalize-package-bin: 2.0.0 + + npm-normalize-package-bin@2.0.0: {} + + npm-packlist@5.1.3: + dependencies: + glob: 8.1.0 + ignore-walk: 5.0.1 + npm-bundled: 2.0.1 + npm-normalize-package-bin: 2.0.0 + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + obug@2.2.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + openai@5.12.2(ws@8.21.3)(zod@3.25.76): + optionalDependencies: + ws: 8.21.3 + zod: 3.25.76 + + openai@6.26.0(ws@8.21.3)(zod@4.6.5): + optionalDependencies: + ws: 8.21.3 + zod: 4.6.5 + + openai@6.49.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)(zod@4.6.5): + optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.83 + '@smithy/signature-v4': 5.7.3 + ws: 8.21.3 + zod: 4.6.5 + + openapi-types@12.1.3: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + os-paths@4.4.0: {} + + outvariant@1.4.3: {} + + p-all@3.0.0: + dependencies: + p-map: 4.0.0 + + p-finally@1.0.0: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + p-queue@6.6.2: + dependencies: + eventemitter3: 4.0.7 + p-timeout: 3.2.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + papaparse@5.7.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.28.6 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse5-htmlparser2-tree-adapter@6.0.1: + dependencies: + parse5: 6.0.1 + + parse5@5.1.1: {} + + parse5@6.0.1: {} + + parseurl@1.3.3: {} + + partial-json@0.1.7: {} + + path-exists@4.0.0: {} + + path-expression-matcher@1.6.2: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.4.2: {} + + pathe@2.0.3: {} + + pg-int8@1.0.1: {} + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pirates@4.0.6: {} + + pirates@4.0.7: {} + + pkce-challenge@5.0.1: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + postcss@8.5.23: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.6 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.96.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + optional: true + + prelude-ls@1.2.1: {} + + prettier@3.1.1: {} + + pretty-format@29.7.0: + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.3.0 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 25.9.8 + long: 5.3.2 + + proxy-addr@2.0.8: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + publint@0.2.12: + dependencies: + npm-packlist: 5.1.3 + picocolors: 1.1.1 + sade: 1.8.1 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + optional: true + + punycode.js@2.3.1: {} + + punycode@2.3.1: {} + + pure-rand@6.0.4: {} + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + quickjs-emscripten-core@0.32.0: + dependencies: + '@jitl/quickjs-ffi-types': 0.32.0 + + quickjs-emscripten@0.32.0: + dependencies: + '@jitl/quickjs-wasmfile-debug-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-debug-sync': 0.32.0 + '@jitl/quickjs-wasmfile-release-asyncify': 0.32.0 + '@jitl/quickjs-wasmfile-release-sync': 0.32.0 + quickjs-emscripten-core: 0.32.0 + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.6 + strip-json-comments: 2.0.1 + optional: true + + re2js@1.3.3: {} + + react-is@18.2.0: {} + + react-is@18.3.1: {} + + react-is@19.3.0: {} + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve.exports@2.0.2: {} + + resolve@1.22.8: + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + retry@0.12.0: {} + + retry@0.13.1: {} + + rettime@0.11.11: {} + + reusify@1.0.4: {} + + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + + rollup@4.63.4: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.4 + '@rollup/rollup-android-arm64': 4.63.4 + '@rollup/rollup-darwin-arm64': 4.63.4 + '@rollup/rollup-darwin-x64': 4.63.4 + '@rollup/rollup-freebsd-arm64': 4.63.4 + '@rollup/rollup-freebsd-x64': 4.63.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.4 + '@rollup/rollup-linux-arm-musleabihf': 4.63.4 + '@rollup/rollup-linux-arm64-gnu': 4.63.4 + '@rollup/rollup-linux-arm64-musl': 4.63.4 + '@rollup/rollup-linux-loong64-gnu': 4.63.4 + '@rollup/rollup-linux-loong64-musl': 4.63.4 + '@rollup/rollup-linux-ppc64-gnu': 4.63.4 + '@rollup/rollup-linux-ppc64-musl': 4.63.4 + '@rollup/rollup-linux-riscv64-gnu': 4.63.4 + '@rollup/rollup-linux-riscv64-musl': 4.63.4 + '@rollup/rollup-linux-s390x-gnu': 4.63.4 + '@rollup/rollup-linux-x64-gnu': 4.63.4 + '@rollup/rollup-linux-x64-musl': 4.63.4 + '@rollup/rollup-openbsd-x64': 4.63.4 + '@rollup/rollup-openharmony-arm64': 4.63.4 + '@rollup/rollup-win32-arm64-msvc': 4.63.4 + '@rollup/rollup-win32-ia32-msvc': 4.63.4 + '@rollup/rollup-win32-x64-gnu': 4.63.4 + '@rollup/rollup-win32-x64-msvc': 4.63.4 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + seek-bzip@2.0.0: + dependencies: + commander: 6.2.1 + + semver@6.3.1: {} + + semver@7.8.0: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@3.1.2: {} + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + sift@17.1.3: {} + + siginfo@2.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-concat@1.0.1: + optional: true + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + + sisteransi@1.0.5: {} + + skin-tone@2.0.0: + dependencies: + unicode-emoji-modifier-base: 1.0.0 + + slash@3.0.0: {} + + smol-toml@1.8.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sparse-bitfield@3.0.3: + dependencies: + memory-pager: 1.5.0 + + sprintf-js@1.1.3: {} + + sql.js@1.14.2: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + stackback@0.0.2: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + std-env@4.2.0: {} + + streamx@2.28.1: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + strict-event-emitter@0.5.1: {} + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-to-stream@3.0.1: + dependencies: + readable-stream: 3.6.2 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@3.0.0: {} + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@2.0.1: + optional: true + + strip-json-comments@3.1.1: {} + + strnum@2.4.2: + dependencies: + anynum: 1.0.1 + + strtok3@10.3.5: + dependencies: + '@tokenizer/token': 0.3.0 + + superstruct@1.0.4: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.1.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + systeminformation@5.33.13: {} + + tagged-tag@1.0.0: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + optional: true + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + optional: true + + tar-stream@3.1.7: + dependencies: + b4a: 1.9.0 + fast-fifo: 1.3.2 + streamx: 2.28.1 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 9.0.9 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.9 + + text-decoder@1.2.7: + dependencies: + b4a: 1.9.0 + transitivePeerDependencies: + - react-native-b4a + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.13: {} + + tldts@7.4.13: + dependencies: + tldts-core: 7.4.13 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + token-types@6.1.2: + dependencies: + '@borewit/text-codec': 0.2.2 + '@tokenizer/token': 0.3.0 + ieee754: 1.2.1 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.13 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + ts-algebra@2.0.0: {} + + ts-api-utils@2.0.1(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + + ts-jest@29.4.6(@babel/core@7.29.7)(@jest/transform@30.5.2)(@jest/types@30.5.1)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@30.5.1)(jest@29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)))(typescript@5.8.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@25.9.8)(ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.8.5 + type-fest: 4.41.0 + typescript: 5.8.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.2 + '@jest/types': 30.5.1 + babel-jest: 29.7.0(@babel/core@7.29.7) + jest-util: 30.5.1 + + ts-node@10.7.0(@swc/core@1.4.16)(@types/node@25.9.8)(typescript@5.8.3): + dependencies: + '@cspotcode/source-map-support': 0.7.0 + '@tsconfig/node10': 1.0.8 + '@tsconfig/node12': 1.0.9 + '@tsconfig/node14': 1.0.1 + '@tsconfig/node16': 1.0.2 + '@types/node': 25.9.8 + acorn: 8.14.0 + acorn-walk: 8.2.0 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.4 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.0 + yn: 3.1.1 + optionalDependencies: + '@swc/core': 1.4.16 + + tsc-multi@https://github.com/stainless-api/tsc-multi/releases/download/v1.1.11/tsc-multi.tgz(typescript@5.8.3): + dependencies: + debug: 4.4.1 + fast-glob: 3.3.2 + get-stdin: 8.0.0 + p-all: 3.0.0 + picocolors: 1.1.1 + signal-exit: 3.0.7 + string-to-stream: 3.0.1 + superstruct: 1.0.4 + tslib: 2.8.1 + typescript: 5.8.3 + yargs: 17.7.2 + transitivePeerDependencies: + - supports-color + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.6 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + optional: true + + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + type-fest@5.10.0: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.1.0: + dependencies: + content-type: 2.1.0 + media-typer: 1.1.1 + mime-types: 3.0.2 + + typebox@1.1.38: {} + + typebox@1.3.34: {} + + typedoc-plugin-expand-object-like-types@0.1.2(typedoc@0.28.20(typescript@5.8.3)): + dependencies: + typedoc: 0.28.20(typescript@5.8.3) + + typedoc@0.28.20(typescript@5.8.3): + dependencies: + '@gerrit0/mini-shiki': 3.23.0 + lunr: 2.3.9 + markdown-it: 14.3.2 + minimatch: 9.0.9 + typescript: 5.8.3 + yaml: 2.9.1 + + typescript-eslint@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.31.1(@typescript-eslint/parser@8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3))(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/parser': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + '@typescript-eslint/utils': 8.31.1(eslint@9.39.1(jiti@2.7.0))(typescript@5.8.3) + eslint: 9.39.1(jiti@2.7.0) + typescript: 5.8.3 + transitivePeerDependencies: + - supports-color + + typescript@5.6.1-rc: {} + + typescript@5.8.3: {} + + uc.micro@2.1.0: {} + + uglify-js@3.19.3: + optional: true + + uint8array-extras@1.5.0: {} + + undici-types@7.24.6: {} + + undici@7.29.1: {} + + undici@8.10.2: {} + + unicode-emoji-modifier-base@1.0.0: {} + + unpipe@1.0.0: {} + + until-async@3.0.2: {} + + update-browserslist-db@1.3.3(browserslist@4.29.0): + dependencies: + browserslist: 4.29.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + uuid@11.1.1: {} + + v8-compile-cache-lib@3.0.0: {} + + v8-to-istanbul@9.2.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + validate-npm-package-name@5.0.1: {} + + vary@1.1.2: {} + + vite@7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.23 + rollup: 4.63.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.8 + fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.9.1 + + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@25.9.8)(msw@2.15.0(@types/node@25.9.8)(typescript@5.8.3))(vite@7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(msw@2.15.0(@types/node@25.9.8)(typescript@5.8.3))(vite@7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.2.1 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@25.9.8)(jiti@2.7.0)(yaml@2.9.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 25.9.8 + transitivePeerDependencies: + - msw + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + web-streams-polyfill@3.3.3: {} + + webidl-conversions@7.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + write-file-atomic@4.0.2: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.21.3: {} + + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + + xml-naming@0.3.0: {} + + xtend@4.0.2: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yaml@2.9.0: {} + + yaml@2.9.1: {} + + yargs-parser@20.2.9: {} + + yargs-parser@21.1.1: {} + + yargs@16.2.0: + dependencies: + cliui: 7.0.4 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.9 + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-json-schema@3.25.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@3.25.76: {} + + zod@4.6.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..256602d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +onlyBuiltDependencies: + - '@swc/core' +minimumReleaseAge: 10080 +minimumReleaseAgeExclude: + - langsmith + - ai + - '@ai-sdk/gateway' + - '@ai-sdk/harness' + - '@ai-sdk/harness-claude-code' + - '@ai-sdk/harness-pi' + - '@ai-sdk/sandbox-just-bash' + - '@ai-sdk/sandbox-vercel' + - smol-toml diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..9f17a90 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,64 @@ +{ + "packages": { + ".": {} + }, + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-v-in-tag": true, + "include-component-in-tag": false, + "versioning": "prerelease", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "pull-request-header": "Automated Release PR", + "pull-request-title-pattern": "release: ${version}", + "changelog-sections": [ + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "chore", + "section": "Chores" + }, + { + "type": "docs", + "section": "Documentation" + }, + { + "type": "style", + "section": "Styles" + }, + { + "type": "refactor", + "section": "Refactors" + }, + { + "type": "test", + "section": "Tests", + "hidden": true + }, + { + "type": "build", + "section": "Build System" + }, + { + "type": "ci", + "section": "Continuous Integration", + "hidden": true + } + ], + "release-type": "node", + "extra-files": ["src/version.ts", "README.md"] +} diff --git a/scripts/bootstrap b/scripts/bootstrap new file mode 100755 index 0000000..3a8293a --- /dev/null +++ b/scripts/bootstrap @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +if [ -f "Brewfile" ] && [ "$(uname -s)" = "Darwin" ] && [ "${SKIP_BREW:-}" != "1" ] && [ -t 0 ]; then + brew bundle check >/dev/null 2>&1 || { + echo -n "==> Install Homebrew dependencies? (y/N): " + read -r response + case "$response" in + [yY][eE][sS]|[yY]) + brew bundle + ;; + *) + ;; + esac + echo + } +fi + +echo "==> Installing Node dependencies…" + +PACKAGE_MANAGER=$(command -v pnpm >/dev/null 2>&1 && echo "pnpm" || echo "npm") + +$PACKAGE_MANAGER install "$@" diff --git a/scripts/build b/scripts/build new file mode 100755 index 0000000..32099c7 --- /dev/null +++ b/scripts/build @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +set -exuo pipefail + +cd "$(dirname "$0")/.." + +node scripts/utils/check-version.cjs + +# Build into dist and will publish the package from there, +# so that src/resources/foo.ts becomes /resources/foo.js +# This way importing from `"langsmith/resources/foo"` works +# even with `"moduleResolution": "node"` + +rm -rf dist; mkdir dist +# Copy src to dist/src and build from dist/src into dist, so that +# the source map for index.js.map will refer to ./src/index.ts etc +cp -rp src README.md dist +# hand-written tests are not part of the package +rm -rf dist/src/lib/tests +for file in LICENSE CHANGELOG.md; do + if [ -e "${file}" ]; then cp "${file}" dist; fi +done +if [ -e "bin/cli" ]; then + mkdir -p dist/bin + cp -p "bin/cli" dist/bin/; +fi +if [ -e "bin/migration-config.json" ]; then + mkdir -p dist/bin + cp -p "bin/migration-config.json" dist/bin/; +fi +# this converts the export map paths for the dist directory +# and does a few other minor things +node scripts/utils/make-dist-package-json.cjs > dist/package.json + +# build to .js/.mjs/.d.ts files +# ESM-only entrypoints (langsmith/vitest*, top-level await) first, then everything else +./node_modules/.bin/tsc-multi --config tsc-multi.vitest.json +./node_modules/.bin/tsc-multi +# we need to patch index.js so that `new module.exports()` works for cjs backwards +# compat. No way to get that from index.ts because it would cause compile errors +# when building .mjs +node scripts/utils/fix-index-exports.cjs +cp tsconfig.dist-src.json dist/src/tsconfig.json + +node scripts/utils/postprocess-files.cjs + +# make sure that nothing crashes when we require the output CJS or +# import the output ESM +(cd dist && node -e 'require("langsmith")') +(cd dist && node -e 'import("langsmith")' --input-type=module) + +if [ -e ./scripts/build-deno ] +then + ./scripts/build-deno +fi diff --git a/scripts/fast-format b/scripts/fast-format new file mode 100755 index 0000000..f1873ae --- /dev/null +++ b/scripts/fast-format @@ -0,0 +1,37 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "Script started with $# arguments" +echo "Arguments: $*" +echo "Script location: $(dirname "$0")" + +cd "$(dirname "$0")/.." +echo "Changed to directory: $(pwd)" + +if [ $# -eq 0 ]; then + echo "Usage: $0 [additional-formatter-args...]" + echo "The file should contain one file path per line" + exit 1 +fi + +FILE_LIST="$1" + +echo "Looking for file: $FILE_LIST" + +if [ ! -f "$FILE_LIST" ]; then + echo "Error: File '$FILE_LIST' not found" + exit 1 +fi + +echo "==> Running eslint --fix" +ESLINT_FILES="$(grep '\.ts$' "$FILE_LIST" || true)" +if ! [ -z "$ESLINT_FILES" ]; then + echo "$ESLINT_FILES" | xargs ./node_modules/.bin/eslint --cache --fix +fi + +echo "==> Running prettier --write" +if ! [ -z "$FILE_LIST" ]; then + cat "$FILE_LIST" | xargs ./node_modules/.bin/prettier \ + --write --cache --cache-strategy metadata --no-error-on-unmatched-pattern --ignore-unknown +fi diff --git a/scripts/format b/scripts/format new file mode 100755 index 0000000..b1b2c17 --- /dev/null +++ b/scripts/format @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running eslint --fix" +./node_modules/.bin/eslint --fix . + +echo "==> Running prettier --write" +./node_modules/.bin/prettier --write --cache --cache-strategy metadata . diff --git a/scripts/lint b/scripts/lint new file mode 100755 index 0000000..23b1873 --- /dev/null +++ b/scripts/lint @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +echo "==> Running prettier --check" +./node_modules/.bin/prettier --check . + +echo "==> Running eslint" +./node_modules/.bin/eslint . + +echo "==> Building" +./scripts/build + +echo "==> Checking types" +./node_modules/typescript/bin/tsc +./node_modules/typescript/bin/tsc -p tsconfig.esm.json + +echo "==> Running Are The Types Wrong?" +./node_modules/.bin/attw --pack dist -f json >.attw.json || true +node scripts/utils/attw-report.cjs + +echo "==> Running publint" +./node_modules/.bin/publint dist diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..f5c8d6f --- /dev/null +++ b/scripts/test @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + + + +# Two jest invocations: jest caches "is this file ESM?" per path across projects, so the +# CJS `stainless` project breaks once the ESM `lib` project has loaded a shared source file. +echo "==> Running tests (generated SDK)" +./node_modules/.bin/jest --selectProjects stainless "$@" + +echo "==> Running tests (hand-written SDK)" +NODE_OPTIONS="${NODE_OPTIONS:-} --experimental-vm-modules" ./node_modules/.bin/jest --selectProjects lib "$@" diff --git a/scripts/typedoc-plugin.cjs b/scripts/typedoc-plugin.cjs new file mode 100644 index 0000000..3cb57dd --- /dev/null +++ b/scripts/typedoc-plugin.cjs @@ -0,0 +1,102 @@ +const { + Application, + Converter, + Context, + ReflectionKind, + DeclarationReflection, + RendererEvent, +} = require('typedoc'); +const fs = require('fs'); +const path = require('path'); + +// Reflection types to check for methods that should not be documented. +// e.g methods prefixed with `_` or `lc_` +const REFLECTION_KINDS_TO_HIDE = [ + ReflectionKind.Property, + ReflectionKind.Accessor, + ReflectionKind.Variable, + ReflectionKind.Method, + ReflectionKind.Function, + ReflectionKind.Class, + ReflectionKind.Interface, + ReflectionKind.Enum, + ReflectionKind.TypeAlias, +]; + +const BASE_OUTPUT_DIR = './_build/api_refs'; + +// Script to inject into the HTML to add a CMD/CTRL + K 'hotkey' which focuses +// on the search input element. +const SCRIPT_HTML = ``; + +/** + * Takes in a reflection and an array of all chat model class names. + * Then performs checks to see if the given reflection should be removed + * from the documentation. + * E.g a class method on chat models which is + * not intended to be documented. + * + * @param {DeclarationReflection} reflection + * @returns {boolean} Whether or not the reflection should be removed + */ +function shouldRemoveReflection(reflection) { + const kind = reflection.kind; + + if (REFLECTION_KINDS_TO_HIDE.find((kindToHide) => kindToHide === kind)) { + if (reflection.name.startsWith('_') || reflection.name.startsWith('ls_')) { + // Remove all reflections which start with an `_` or `ls_` as those are internal + return true; + } + } + return false; +} + +/** + * @param {Application} application + * @returns {void} + */ +function load(application) { + application.converter.on(Converter.EVENT_CREATE_DECLARATION, resolveReflection); + + application.renderer.on(RendererEvent.END, onEndRenderEvent); + + /** + * @param {Context} context + * @param {DeclarationReflection} reflection + * @returns {void} + */ + function resolveReflection(context, reflection) { + const { project } = context; + + if (shouldRemoveReflection(reflection)) { + project.removeReflection(reflection); + } + } + + /** + * @param {Context} context + */ + function onEndRenderEvent(context) { + const htmlToSplitAtSearchScript = `
`; + + const { urls } = context; + for (const { url } of urls) { + const indexFilePath = path.join(BASE_OUTPUT_DIR, url); + const htmlFileContent = fs.readFileSync(indexFilePath, 'utf-8'); + + const [part1, part2] = htmlFileContent.split(htmlToSplitAtSearchScript); + const htmlWithScript = part1 + SCRIPT_HTML + part2; + fs.writeFileSync(indexFilePath, htmlWithScript); + } + } +} + +module.exports = { load }; diff --git a/scripts/utils/attw-report.cjs b/scripts/utils/attw-report.cjs new file mode 100644 index 0000000..7c5d102 --- /dev/null +++ b/scripts/utils/attw-report.cjs @@ -0,0 +1,26 @@ +const fs = require('fs'); +const problems = Object.values(JSON.parse(fs.readFileSync('.attw.json', 'utf-8')).problems) + .flat() + .filter( + (problem) => + !( + // This is intentional, if the user specifies .mjs they get ESM. + ( + (problem.kind === 'CJSResolvesToESM' && problem.entrypoint.endsWith('.mjs')) || + // This is intentional for backwards compat reasons. + (problem.kind === 'MissingExportEquals' && problem.implementationFileName.endsWith('/index.js')) || + // this is intentional, we deliberately attempt to import types that may not exist from parent node_modules + // folders to better support various runtimes without triggering automatic type acquisition. + (problem.kind === 'InternalResolutionError' && problem.moduleSpecifier.includes('node_modules')) || + // langsmith/vitest is ESM-only (top-level await), as it was in langsmith-sdk: no CJS/node10 resolution. + (problem.kind === 'NoResolution' && /^\.\/vitest(\/|$)/.test(problem.entrypoint)) + ) + ), + ); +fs.unlinkSync('.attw.json'); +if (problems.length) { + process.stdout.write('The types are wrong!\n' + JSON.stringify(problems, null, 2) + '\n'); + process.exitCode = 1; +} else { + process.stdout.write('Types ok!\n'); +} diff --git a/scripts/utils/check-is-in-git-install.sh b/scripts/utils/check-is-in-git-install.sh new file mode 100755 index 0000000..1354eb4 --- /dev/null +++ b/scripts/utils/check-is-in-git-install.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Check if you happen to call prepare for a repository that's already in node_modules. +[ "$(basename "$(dirname "$PWD")")" = 'node_modules' ] || +# The name of the containing directory that 'npm` uses, which looks like +# $HOME/.npm/_cacache/git-cloneXXXXXX +[ "$(basename "$(dirname "$PWD")")" = 'tmp' ] || +# The name of the containing directory that 'yarn` uses, which looks like +# $(yarn cache dir)/.tmp/XXXXX +[ "$(basename "$(dirname "$PWD")")" = '.tmp' ] diff --git a/scripts/utils/check-version.cjs b/scripts/utils/check-version.cjs new file mode 100644 index 0000000..86c56df --- /dev/null +++ b/scripts/utils/check-version.cjs @@ -0,0 +1,20 @@ +const fs = require('fs'); +const path = require('path'); + +const main = () => { + const pkg = require('../../package.json'); + const version = pkg['version']; + if (!version) throw 'The version property is not set in the package.json file'; + if (typeof version !== 'string') { + throw `Unexpected type for the package.json version field; got ${typeof version}, expected string`; + } + + const versionFile = path.resolve(__dirname, '..', '..', 'src', 'version.ts'); + const contents = fs.readFileSync(versionFile, 'utf8'); + const output = contents.replace(/(export const VERSION = ')(.*)(')/g, `$1${version}$3`); + fs.writeFileSync(versionFile, output); +}; + +if (require.main === module) { + main(); +} diff --git a/scripts/utils/fix-index-exports.cjs b/scripts/utils/fix-index-exports.cjs new file mode 100644 index 0000000..e5e10b3 --- /dev/null +++ b/scripts/utils/fix-index-exports.cjs @@ -0,0 +1,17 @@ +const fs = require('fs'); +const path = require('path'); + +const indexJs = + process.env['DIST_PATH'] ? + path.resolve(process.env['DIST_PATH'], 'index.js') + : path.resolve(__dirname, '..', '..', 'dist', 'index.js'); + +let before = fs.readFileSync(indexJs, 'utf8'); +let after = before.replace( + /^(\s*Object\.defineProperty\s*\(exports,\s*["']__esModule["'].+)$/m, + `exports = module.exports = function (...args) { + return new exports.default(...args) + } + $1`.replace(/^ /gm, ''), +); +fs.writeFileSync(indexJs, after, 'utf8'); diff --git a/scripts/utils/git-swap.sh b/scripts/utils/git-swap.sh new file mode 100755 index 0000000..79d1888 --- /dev/null +++ b/scripts/utils/git-swap.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -exuo pipefail +# the package is published to NPM from ./dist +# we want the final file structure for git installs to match the npm installs, so we + +# delete everything except ./dist and ./node_modules +find . -maxdepth 1 -mindepth 1 ! -name 'dist' ! -name 'node_modules' -exec rm -rf '{}' + + +# move everything from ./dist to . +mv dist/* . + +# delete the now-empty ./dist +rmdir dist diff --git a/scripts/utils/make-dist-package-json.cjs b/scripts/utils/make-dist-package-json.cjs new file mode 100644 index 0000000..8d2391a --- /dev/null +++ b/scripts/utils/make-dist-package-json.cjs @@ -0,0 +1,30 @@ +const pkgJson = require(process.env['PKG_JSON_PATH'] || '../../package.json'); + +function processExportMap(m) { + for (const key in m) { + const value = m[key]; + if (typeof value === 'string') m[key] = value.replace(/^\.\/dist\//, './'); + else processExportMap(value); + } +} +processExportMap(pkgJson.exports); + +for (const key of ['types', 'main', 'module']) { + if (typeof pkgJson[key] === 'string') pkgJson[key] = pkgJson[key].replace(/^(\.\/)?dist\//, './'); +} + +if (pkgJson.browser) { + pkgJson.browser = Object.fromEntries( + Object.entries(pkgJson.browser).map(([k, v]) => [ + k.replace(/^\.\/dist\//, './'), + v.replace(/^\.\/dist\//, './'), + ]), + ); +} + +delete pkgJson.devDependencies; +delete pkgJson.scripts.prepack; +delete pkgJson.scripts.prepublishOnly; +delete pkgJson.scripts.prepare; + +console.log(JSON.stringify(pkgJson, null, 2)); diff --git a/scripts/utils/postprocess-files.cjs b/scripts/utils/postprocess-files.cjs new file mode 100644 index 0000000..129dc74 --- /dev/null +++ b/scripts/utils/postprocess-files.cjs @@ -0,0 +1,108 @@ +// @ts-check +const fs = require('fs'); +const path = require('path'); + +const distDir = + process.env['DIST_PATH'] ? + path.resolve(process.env['DIST_PATH']) + : path.resolve(__dirname, '..', '..', 'dist'); + +async function* walk(dir) { + for await (const d of await fs.promises.opendir(dir)) { + const entry = path.join(dir, d.name); + if (d.isDirectory()) yield* walk(entry); + else if (d.isFile()) yield entry; + } +} + +async function postprocess() { + for await (const file of walk(distDir)) { + if (!/(\.d)?[cm]?ts$/.test(file)) continue; + + const code = await fs.promises.readFile(file, 'utf8'); + + // strip out lib="dom", types="node", and types="react" references; these + // are needed at build time, but would pollute the user's TS environment + let transformed = code.replace( + /^ *\/\/\/ * ' '.repeat(match.length - 1) + '\n', + ); + + // TypeScript's declaration emitter collapses /** @ts-ignore */ onto the same + // line as the type declaration, which doesn't work. So we convert to // @ts-ignore + // on its own line to properly suppresses errors. + if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) { + transformed = transformed.replace(/\/\*\* @ts-ignore\b[^*]*\*\/ /gm, '// @ts-ignore\n'); + } + + if (transformed !== code) { + console.error(`wrote ${path.relative(process.cwd(), file)}`); + await fs.promises.writeFile(file, transformed, 'utf8'); + } + } + + const newExports = { + '.': { + require: { + types: './index.d.ts', + default: './index.js', + }, + types: './index.d.mts', + default: './index.mjs', + }, + }; + + for (const entry of await fs.promises.readdir(distDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== 'src' && entry.name !== 'internal' && entry.name !== 'bin') { + const subpath = './' + entry.name; + newExports[subpath + '/*.mjs'] = { + default: subpath + '/*.mjs', + }; + newExports[subpath + '/*.js'] = { + default: subpath + '/*.js', + }; + newExports[subpath + '/*'] = { + import: subpath + '/*.mjs', + require: subpath + '/*.js', + }; + } else if (entry.isFile() && /\.[cm]?js$/.test(entry.name)) { + const { name, ext } = path.parse(entry.name); + const subpathWithoutExt = './' + name; + const subpath = './' + entry.name; + newExports[subpathWithoutExt] ||= { import: undefined, require: undefined }; + const isModule = ext[1] === 'm'; + if (isModule) { + newExports[subpathWithoutExt].import = subpath; + } else { + newExports[subpathWithoutExt].require = subpath; + } + newExports[subpath] = { + default: subpath, + }; + } + } + // entrypoints package.json points into lib/ (hand-written code) win over the generated file of the same name; + // `./package.json` is kept as in langsmith-sdk + const distPkg = JSON.parse(await fs.promises.readFile('dist/package.json', 'utf-8')); + for (const [subpath, target] of Object.entries(distPkg.exports ?? {})) { + if (subpath === './package.json' || JSON.stringify(target).includes('./lib/')) + newExports[subpath] = target; + } + await fs.promises.writeFile( + 'dist/package.json', + JSON.stringify( + Object.assign( + /** @type {Record} */ ( + JSON.parse(await fs.promises.readFile('dist/package.json', 'utf-8')) + ), + { + exports: newExports, + }, + ), + null, + 2, + ), + ); +} +postprocess(); diff --git a/scripts/utils/upload-artifact.sh b/scripts/utils/upload-artifact.sh new file mode 100755 index 0000000..1606c56 --- /dev/null +++ b/scripts/utils/upload-artifact.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -exuo pipefail + +RESPONSE=$(curl -X POST "$URL" \ + -H "Authorization: Bearer $AUTH" \ + -H "Content-Type: application/json") + +SIGNED_URL=$(echo "$RESPONSE" | jq -r '.url') + +if [[ "$SIGNED_URL" == "null" ]]; then + echo -e "\033[31mFailed to get signed URL.\033[0m" + exit 1 +fi + +TARBALL=$(cd dist && npm pack --silent) + +UPLOAD_RESPONSE=$(curl -v -X PUT \ + -H "Content-Type: application/gzip" \ + --data-binary "@dist/$TARBALL" "$SIGNED_URL" 2>&1) + +if echo "$UPLOAD_RESPONSE" | grep -q "HTTP/[0-9.]* 200"; then + echo -e "\033[32mUploaded build to Stainless storage.\033[0m" + echo -e "\033[32mInstallation: npm install 'https://pkg.stainless.com/s/langsmith-javascript-staging/$SHA'\033[0m" +else + echo -e "\033[31mFailed to upload artifact.\033[0m" + exit 1 +fi diff --git a/src/anonymizer.ts b/src/anonymizer.ts new file mode 100644 index 0000000..dfbf68d --- /dev/null +++ b/src/anonymizer.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/anonymizer/index'; diff --git a/src/api-promise.ts b/src/api-promise.ts new file mode 100644 index 0000000..8c775ee --- /dev/null +++ b/src/api-promise.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/api-promise instead */ +export * from './core/api-promise'; diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..1d48cd1 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,1171 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { RequestInit, RequestInfo, BodyInit } from './internal/builtin-types'; +import type { HTTPMethod, PromiseOrValue, MergedRequestInit, FinalizedRequestInit } from './internal/types'; +import { uuid4 } from './internal/utils/uuid'; +import { validatePositiveInteger, isAbsoluteURL, safeJSON } from './internal/utils/values'; +import { sleep } from './internal/utils/sleep'; +export type { Logger, LogLevel } from './internal/utils/log'; +import { castToError, isAbortError } from './internal/errors'; +import type { APIResponseProps } from './internal/parse'; +import { getPlatformHeaders } from './internal/detect-platform'; +import * as Shims from './internal/shims'; +import * as Opts from './internal/request-options'; +import { stringifyQuery } from './internal/utils/query'; +import { VERSION } from './version'; +import * as Errors from './core/error'; +import * as Pagination from './core/pagination'; +import { + AbstractPage, + type CursorPaginationParams, + CursorPaginationResponse, + type ItemsCursorGetPaginationParams, + ItemsCursorGetPaginationResponse, + type ItemsCursorPostPaginationParams, + ItemsCursorPostPaginationResponse, + type OffsetPaginationCommitsParams, + OffsetPaginationCommitsResponse, + type OffsetPaginationInsightsClusteringJobsParams, + OffsetPaginationInsightsClusteringJobsResponse, + type OffsetPaginationIssuesParams, + OffsetPaginationIssuesResponse, + type OffsetPaginationOnlineEvaluatorsParams, + OffsetPaginationOnlineEvaluatorsResponse, + type OffsetPaginationReposParams, + OffsetPaginationReposResponse, + type OffsetPaginationTopLevelArrayParams, + OffsetPaginationTopLevelArrayResponse, +} from './core/pagination'; +import * as Uploads from './core/uploads'; +import * as API from './resources/index'; +import { APIPromise } from './core/api-promise'; +import { Info, InfoListResponse } from './resources/info'; +import { + Issue, + IssueListParams, + IssueRetrieveParams, + Issues, + IssuesOffsetPaginationIssues, +} from './resources/issues'; +import { + BulkDeleteEvaluatorFailedItem, + BulkDeleteEvaluatorsResponse, + CreateOnlineCodeEvaluatorRequest, + CreateOnlineEvaluatorRequest, + CreateOnlineEvaluatorResponse, + CreateOnlineLlmEvaluatorRequest, + GetOnlineEvaluatorSpendResponse, + OnlineCodeEvaluator, + OnlineEvaluator, + OnlineEvaluatorBulkDeleteParams, + OnlineEvaluatorCreateParams, + OnlineEvaluatorDeleteParams, + OnlineEvaluatorListParams, + OnlineEvaluatorRunRule, + OnlineEvaluatorSpendDay, + OnlineEvaluatorSpendGroup, + OnlineEvaluatorSpendParams, + OnlineEvaluatorType, + OnlineEvaluatorUpdateParams, + OnlineEvaluators, + OnlineEvaluatorsOffsetPaginationOnlineEvaluators, + OnlineLlmEvaluator, + OnlineSpendLimit, + UpdateOnlineCodeEvaluatorRequest, + UpdateOnlineEvaluatorRequest, + UpdateOnlineEvaluatorResponse, + UpdateOnlineLlmEvaluatorRequest, +} from './resources/online-evaluators'; +import { + ProductFeedback, + ProductFeedbackCreateParams, + ProductFeedbackCreateResponse, + ProductFeedbackRetrieveResponse, +} from './resources/product-feedback'; +import { + Trace, + TraceAggregates, + TraceListRunsParams, + TraceListRunsResponse, + TraceQueryParams, + Traces, + TracesItemsCursorPostPagination, +} from './resources/traces'; +import { + AnnotationQueueAnnotationQueuesParams, + AnnotationQueueCreateRunStatusParams, + AnnotationQueueCreateRunStatusResponse, + AnnotationQueueDeleteResponse, + AnnotationQueueExportParams, + AnnotationQueueExportResponse, + AnnotationQueuePopulateParams, + AnnotationQueuePopulateResponse, + AnnotationQueueRetrieveAnnotationQueuesParams, + AnnotationQueueRetrieveAnnotationQueuesResponse, + AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, + AnnotationQueueRetrieveQueuesResponse, + AnnotationQueueRetrieveResponse, + AnnotationQueueRetrieveRunParams, + AnnotationQueueRetrieveSizeParams, + AnnotationQueueRetrieveTotalArchivedParams, + AnnotationQueueRubricItemSchema, + AnnotationQueueSchema, + AnnotationQueueSizeSchema, + AnnotationQueueUpdateParams, + AnnotationQueueUpdateResponse, + AnnotationQueues, + RunSchemaWithAnnotationQueueInfo, +} from './resources/annotation-queues/annotation-queues'; +import { + DataType, + Dataset, + DatasetTransformation, + DatasetVersion, + Datasets, + FeedbackCreateCoreSchema, + Missing, + SortByDatasetColumn, +} from './resources/datasets/datasets'; +import { Fleet } from './resources/fleet/fleet'; +import { Public } from './resources/public/public'; +import { + ResponseBodyForRunsGenerateQuery, + Run, + RunGetURLParams, + RunGetURLResponse, + RunIngest, + RunQueryV2Params, + RunRetrieveV2Params, + RunSchema, + RunSelectField, + RunStatsQueryParams, + RunType, + RunTypeEnum, + Runs, + RunsFilterDataSourceTypeEnum, + RunsItemsCursorPostPagination, +} from './resources/runs/runs'; +import { + DownloadURLResponse, + SandboxListResponse, + SandboxListUsageCostsParams, + SandboxListUsageCostsResponse, + SandboxListUsageCostsResponsesItemsCursorGetPagination, + SandboxResponse, + SandboxStatusResponse, + Sandboxes, + ServiceURLResponse, + SnapshotListResponse, + SnapshotResponse, +} from './resources/sandboxes/sandboxes'; +import { + Thread, + ThreadAggregateStatsParams, + ThreadAggregateStatsResponse, + ThreadListTracesParams, + ThreadQueryParams, + ThreadStats, + ThreadStatsParams, + ThreadTrace, + ThreadTracesItemsCursorGetPagination, + Threads, + ThreadsItemsCursorPostPagination, +} from './resources/threads/threads'; +import { type Fetch } from './internal/builtin-types'; +import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; +import { FinalRequestOptions, RequestOptions } from './internal/request-options'; +import { readEnv } from './internal/utils/env'; +import { + type LogLevel, + type Logger, + formatRequestDetails, + loggerFor, + parseLogLevel, +} from './internal/utils/log'; +import { isEmptyObj } from './internal/utils/values'; + +export interface ClientOptions { + /** + * Defaults to process.env['LANGSMITH_API_KEY']. + */ + apiKey?: string | null | undefined; + + /** + * Defaults to process.env['LANGSMITH_TENANT_ID']. + */ + tenantID?: string | null | undefined; + + /** + * Override the default base URL for the API, e.g., "https://api.example.com/v2/" + * + * Defaults to process.env['LANGCHAIN_BASE_URL']. + */ + baseURL?: string | null | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * Note that request timeouts are retried by default, so in a worst-case scenario you may wait + * much longer than this timeout before the promise succeeds or fails. + * + * @unit milliseconds + */ + timeout?: number | undefined; + /** + * Additional `RequestInit` options to be passed to `fetch` calls. + * Properties will be overridden by per-request `fetchOptions`. + */ + fetchOptions?: MergedRequestInit | undefined; + + /** + * Specify a custom `fetch` function implementation. + * + * If not provided, we expect that `fetch` is defined globally. + */ + fetch?: Fetch | undefined; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number | undefined; + + /** + * Default headers to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * header to `null` in request options. + */ + defaultHeaders?: HeadersLike | undefined; + + /** + * Default query parameters to include with every request to the API. + * + * These can be removed in individual requests by explicitly setting the + * param to `undefined` in request options. + */ + defaultQuery?: Record | undefined; + + /** + * Set the log level. + * + * Defaults to process.env['LANGCHAIN_LOG'] or 'warn' if it isn't set. + */ + logLevel?: LogLevel | undefined; + + /** + * Set the logger. + * + * Defaults to globalThis.console. + */ + logger?: Logger | undefined; +} + +/** + * API Client for interfacing with the LangChain API. + */ +export class Langsmith { + apiKey: string | null; + tenantID: string | null; + + baseURL: string; + maxRetries: number; + timeout: number; + logger: Logger; + logLevel: LogLevel | undefined; + fetchOptions: MergedRequestInit | undefined; + + private fetch: Fetch; + #encoder: Opts.RequestEncoder; + protected idempotencyHeader?: string; + private _options: ClientOptions; + + /** + * API Client for interfacing with the LangChain API. + * + * @param {string | null | undefined} [opts.apiKey=process.env['LANGSMITH_API_KEY'] ?? null] + * @param {string | null | undefined} [opts.tenantID=process.env['LANGSMITH_TENANT_ID'] ?? null] + * @param {string} [opts.baseURL=process.env['LANGCHAIN_BASE_URL'] ?? https://api.smith.langchain.com/] - Override the default base URL for the API. + * @param {number} [opts.timeout=1.5 minutes] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. + * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. + * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. + * @param {number} [opts.maxRetries=2] - The maximum number of times the client will retry a request. + * @param {HeadersLike} opts.defaultHeaders - Default headers to include with every request to the API. + * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. + */ + constructor({ + baseURL = readEnv('LANGCHAIN_BASE_URL'), + apiKey = readEnv('LANGSMITH_API_KEY') ?? null, + tenantID = readEnv('LANGSMITH_TENANT_ID') ?? null, + ...opts + }: ClientOptions = {}) { + const options: ClientOptions = { + apiKey, + tenantID, + ...opts, + baseURL: baseURL || `https://api.smith.langchain.com/`, + }; + + this.baseURL = options.baseURL!; + this.timeout = options.timeout ?? Langsmith.DEFAULT_TIMEOUT /* 1.5 minutes */; + this.logger = options.logger ?? console; + const defaultLogLevel = 'warn'; + // Set default logLevel early so that we can log a warning in parseLogLevel. + this.logLevel = defaultLogLevel; + this.logLevel = + parseLogLevel(options.logLevel, 'ClientOptions.logLevel', this) ?? + parseLogLevel(readEnv('LANGCHAIN_LOG'), "process.env['LANGCHAIN_LOG']", this) ?? + defaultLogLevel; + this.fetchOptions = options.fetchOptions; + this.maxRetries = options.maxRetries ?? 2; + this.fetch = options.fetch ?? Shims.getDefaultFetch(); + this.#encoder = Opts.FallbackEncoder; + + const customHeadersEnv = readEnv('LANGCHAIN_CUSTOM_HEADERS'); + if (customHeadersEnv) { + const parsed: Record = {}; + for (const line of customHeadersEnv.split('\n')) { + const colon = line.indexOf(':'); + if (colon >= 0) { + parsed[line.substring(0, colon).trim()] = line.substring(colon + 1).trim(); + } + } + options.defaultHeaders = { ...parsed, ...options.defaultHeaders }; + } + + this._options = options; + + this.apiKey = apiKey; + this.tenantID = tenantID; + } + + /** + * Create a new client instance re-using the same options given to the current client with optional overriding. + */ + withOptions(options: Partial): this { + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + ...this._options, + baseURL: this.baseURL, + maxRetries: this.maxRetries, + timeout: this.timeout, + logger: this.logger, + logLevel: this.logLevel, + fetch: this.fetch, + fetchOptions: this.fetchOptions, + apiKey: this.apiKey, + tenantID: this.tenantID, + ...options, + }); + return client; + } + + /** + * Check whether the base URL is set to its default. + */ + #baseURLOverridden(): boolean { + return this.baseURL !== 'https://api.smith.langchain.com/'; + } + + protected defaultQuery(): Record | undefined { + return this._options.defaultQuery; + } + + protected validateHeaders({ values, nulls }: NullableHeaders) { + if (this.apiKey && values.get('x-api-key')) { + return; + } + if (nulls.has('x-api-key')) { + return; + } + + if (this.tenantID && values.get('x-tenant-id')) { + return; + } + if (nulls.has('x-tenant-id')) { + return; + } + + throw new Error( + 'Could not resolve authentication method. Expected either apiKey or tenantID to be set. Or for one of the "X-API-Key" or "X-Tenant-Id" headers to be explicitly omitted', + ); + } + + protected async authHeaders(opts: FinalRequestOptions): Promise { + return buildHeaders([await this.apiKeyAuth(opts), await this.tenantIDAuth(opts)]); + } + + protected async apiKeyAuth(opts: FinalRequestOptions): Promise { + if (this.apiKey == null) { + return undefined; + } + return buildHeaders([{ 'X-API-Key': this.apiKey }]); + } + + protected async tenantIDAuth(opts: FinalRequestOptions): Promise { + if (this.tenantID == null) { + return undefined; + } + return buildHeaders([{ 'X-Tenant-Id': this.tenantID }]); + } + + protected stringifyQuery(query: object | Record): string { + return stringifyQuery(query); + } + + private getUserAgent(): string { + return `${this.constructor.name}/JS ${VERSION}`; + } + + protected defaultIdempotencyKey(): string { + return `stainless-node-retry-${uuid4()}`; + } + + protected makeStatusError( + status: number, + error: Object, + message: string | undefined, + headers: Headers, + ): Errors.APIError { + return Errors.APIError.generate(status, error, message, headers); + } + + buildURL( + path: string, + query: Record | null | undefined, + defaultBaseURL?: string | undefined, + ): string { + const baseURL = (!this.#baseURLOverridden() && defaultBaseURL) || this.baseURL; + const url = + isAbsoluteURL(path) ? + new URL(path) + : new URL(baseURL + (baseURL.endsWith('/') && path.startsWith('/') ? path.slice(1) : path)); + + const defaultQuery = this.defaultQuery(); + const pathQuery = Object.fromEntries(url.searchParams); + if (!isEmptyObj(defaultQuery) || !isEmptyObj(pathQuery)) { + query = { ...pathQuery, ...defaultQuery, ...query }; + } + + if (typeof query === 'object' && query && !Array.isArray(query)) { + url.search = this.stringifyQuery(query); + } + + return url.toString(); + } + + /** + * Used as a callback for mutating the given `FinalRequestOptions` object. + */ + protected async prepareOptions(options: FinalRequestOptions): Promise {} + + /** + * Used as a callback for mutating the given `RequestInit` object. + * + * This is useful for cases where you want to add certain headers based off of + * the request properties, e.g. `method` or `url`. + */ + protected async prepareRequest( + request: RequestInit, + { url, options }: { url: string; options: FinalRequestOptions }, + ): Promise {} + + get(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('get', path, opts); + } + + post(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('post', path, opts); + } + + patch(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('patch', path, opts); + } + + put(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('put', path, opts); + } + + delete(path: string, opts?: PromiseOrValue): APIPromise { + return this.methodRequest('delete', path, opts); + } + + private methodRequest( + method: HTTPMethod, + path: string, + opts?: PromiseOrValue, + ): APIPromise { + return this.request( + Promise.resolve(opts).then((opts) => { + return { method, path, ...opts }; + }), + ); + } + + request( + options: PromiseOrValue, + remainingRetries: number | null = null, + ): APIPromise { + return new APIPromise(this, this.makeRequest(options, remainingRetries, undefined)); + } + + private async makeRequest( + optionsInput: PromiseOrValue, + retriesRemaining: number | null, + retryOfRequestLogID: string | undefined, + ): Promise { + const options = await optionsInput; + const maxRetries = options.maxRetries ?? this.maxRetries; + if (retriesRemaining == null) { + retriesRemaining = maxRetries; + } + + await this.prepareOptions(options); + + const { req, url, timeout } = await this.buildRequest(options, { + retryCount: maxRetries - retriesRemaining, + }); + + await this.prepareRequest(req, { url, options }); + + /** Not an API request ID, just for correlating local log entries. */ + const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0'); + const retryLogStr = retryOfRequestLogID === undefined ? '' : `, retryOf: ${retryOfRequestLogID}`; + const startTime = Date.now(); + + loggerFor(this).debug( + `[${requestLogID}] sending request`, + formatRequestDetails({ + retryOfRequestLogID, + method: options.method, + url, + options, + headers: req.headers, + }), + ); + + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + + const controller = new AbortController(); + const response = await this.fetchWithTimeout(url, req, timeout, controller).catch(castToError); + const headersTime = Date.now(); + + if (response instanceof globalThis.Error) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + if (options.signal?.aborted) { + throw new Errors.APIUserAbortError(); + } + // detect native connection timeout errors + // deno throws "TypeError: error sending request for url (https://example/): client error (Connect): tcp connect error: Operation timed out (os error 60): Operation timed out (os error 60)" + // undici throws "TypeError: fetch failed" with cause "ConnectTimeoutError: Connect Timeout Error (attempted address: example:443, timeout: 1ms)" + // others do not provide enough information to distinguish timeouts from other connection errors + const isTimeout = + isAbortError(response) || + /timed? ?out/i.test(String(response) + ('cause' in response ? String(response.cause) : '')); + if (retriesRemaining) { + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - ${retryMessage}`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + return this.retryRequest(options, retriesRemaining, retryOfRequestLogID ?? requestLogID); + } + loggerFor(this).info( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} - error; no more retries left`, + ); + loggerFor(this).debug( + `[${requestLogID}] connection ${isTimeout ? 'timed out' : 'failed'} (error; no more retries left)`, + formatRequestDetails({ + retryOfRequestLogID, + url, + durationMs: headersTime - startTime, + message: response.message, + }), + ); + if (isTimeout) { + throw new Errors.APIConnectionTimeoutError(); + } + throw new Errors.APIConnectionError({ cause: response }); + } + + const responseInfo = `[${requestLogID}${retryLogStr}] ${req.method} ${url} ${ + response.ok ? 'succeeded' : 'failed' + } with status ${response.status} in ${headersTime - startTime}ms`; + + if (!response.ok) { + const shouldRetry = await this.shouldRetry(response); + if (retriesRemaining && shouldRetry) { + const retryMessage = `retrying, ${retriesRemaining} attempts remaining`; + + // We don't need the body of this response. + await Shims.CancelReadableStream(response.body); + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + return this.retryRequest( + options, + retriesRemaining, + retryOfRequestLogID ?? requestLogID, + response.headers, + ); + } + + const retryMessage = shouldRetry ? `error; no more retries left` : `error; not retryable`; + + loggerFor(this).info(`${responseInfo} - ${retryMessage}`); + + const errText = await response.text().catch((err: any) => castToError(err).message); + const errJSON = safeJSON(errText) as any; + const errMessage = errJSON ? undefined : errText; + + loggerFor(this).debug( + `[${requestLogID}] response error (${retryMessage})`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + message: errMessage, + durationMs: Date.now() - startTime, + }), + ); + + const err = this.makeStatusError(response.status, errJSON, errMessage, response.headers); + throw err; + } + + loggerFor(this).info(responseInfo); + loggerFor(this).debug( + `[${requestLogID}] response start`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + headers: response.headers, + durationMs: headersTime - startTime, + }), + ); + + return { response, options, controller, requestLogID, retryOfRequestLogID, startTime }; + } + + getAPIList = Pagination.AbstractPage>( + path: string, + Page: new (...args: any[]) => PageClass, + opts?: PromiseOrValue, + ): Pagination.PagePromise { + return this.requestAPIList( + Page, + opts && 'then' in opts ? + opts.then((opts) => ({ method: 'get', path, ...opts })) + : { method: 'get', path, ...opts }, + ); + } + + requestAPIList< + Item = unknown, + PageClass extends Pagination.AbstractPage = Pagination.AbstractPage, + >( + Page: new (...args: ConstructorParameters) => PageClass, + options: PromiseOrValue, + ): Pagination.PagePromise { + const request = this.makeRequest(options, null, undefined); + return new Pagination.PagePromise(this as any as Langsmith, request, Page); + } + + async fetchWithTimeout( + url: RequestInfo, + init: RequestInit | undefined, + ms: number, + controller: AbortController, + ): Promise { + const { signal, method, ...options } = init || {}; + const abort = this._makeAbort(controller); + if (signal) signal.addEventListener('abort', abort, { once: true }); + + const timeout = setTimeout(abort, ms); + + const isReadableBody = + ((globalThis as any).ReadableStream && options.body instanceof (globalThis as any).ReadableStream) || + (typeof options.body === 'object' && options.body !== null && Symbol.asyncIterator in options.body); + + const fetchOptions: RequestInit = { + signal: controller.signal as any, + ...(isReadableBody ? { duplex: 'half' } : {}), + method: 'GET', + ...options, + }; + if (method) { + // Custom methods like 'patch' need to be uppercased + // See https://github.com/nodejs/undici/issues/2294 + fetchOptions.method = method.toUpperCase(); + } + + try { + // use undefined this binding; fetch errors if bound to something else in browser/cloudflare + return await this.fetch.call(undefined, url, fetchOptions); + } finally { + clearTimeout(timeout); + } + } + + private async shouldRetry(response: Response): Promise { + // Note this is not a standard header. + const shouldRetryHeader = response.headers.get('x-should-retry'); + + // If the server explicitly says whether or not to retry, obey. + if (shouldRetryHeader === 'true') return true; + if (shouldRetryHeader === 'false') return false; + + // Retry on request timeouts. + if (response.status === 408) return true; + + // Retry on lock timeouts. + if (response.status === 409) return true; + + // Retry on rate limits. + if (response.status === 429) return true; + + // Retry internal errors. + if (response.status >= 500) return true; + + return false; + } + + private async retryRequest( + options: FinalRequestOptions, + retriesRemaining: number, + requestLogID: string, + responseHeaders?: Headers | undefined, + ): Promise { + let timeoutMillis: number | undefined; + + // Note the `retry-after-ms` header may not be standard, but is a good idea and we'd like proactive support for it. + const retryAfterMillisHeader = responseHeaders?.get('retry-after-ms'); + if (retryAfterMillisHeader) { + const timeoutMs = parseFloat(retryAfterMillisHeader); + if (!Number.isNaN(timeoutMs)) { + timeoutMillis = timeoutMs; + } + } + + // About the Retry-After header: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After + const retryAfterHeader = responseHeaders?.get('retry-after'); + if (retryAfterHeader && !timeoutMillis) { + const timeoutSeconds = parseFloat(retryAfterHeader); + if (!Number.isNaN(timeoutSeconds)) { + timeoutMillis = timeoutSeconds * 1000; + } else { + timeoutMillis = Date.parse(retryAfterHeader) - Date.now(); + } + } + + // If the API asks us to wait a certain amount of time, just do what it + // says, but otherwise calculate a default + if (timeoutMillis === undefined) { + const maxRetries = options.maxRetries ?? this.maxRetries; + timeoutMillis = this.calculateDefaultRetryTimeoutMillis(retriesRemaining, maxRetries); + } + await sleep(timeoutMillis); + + return this.makeRequest(options, retriesRemaining - 1, requestLogID); + } + + private calculateDefaultRetryTimeoutMillis(retriesRemaining: number, maxRetries: number): number { + const initialRetryDelay = 0.5; + const maxRetryDelay = 16.0; + + const numRetries = maxRetries - retriesRemaining; + + // Apply exponential backoff, but not more than the max. + const sleepSeconds = Math.min(initialRetryDelay * Math.pow(2, numRetries), maxRetryDelay); + + // Apply some jitter, take up to at most 25 percent of the retry time. + const jitter = 1 - Math.random() * 0.25; + + return sleepSeconds * jitter * 1000; + } + + async buildRequest( + inputOptions: FinalRequestOptions, + { retryCount = 0 }: { retryCount?: number } = {}, + ): Promise<{ req: FinalizedRequestInit; url: string; timeout: number }> { + const options = { ...inputOptions }; + const { method, path, query, defaultBaseURL } = options; + + const url = this.buildURL(path!, query as Record, defaultBaseURL); + if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); + options.timeout = options.timeout ?? this.timeout; + const { bodyHeaders, body } = this.buildBody({ options }); + const reqHeaders = await this.buildHeaders({ options: inputOptions, method, bodyHeaders, retryCount }); + + const req: FinalizedRequestInit = { + method, + headers: reqHeaders, + ...(options.signal && { signal: options.signal }), + ...((globalThis as any).ReadableStream && + body instanceof (globalThis as any).ReadableStream && { duplex: 'half' }), + ...(body && { body }), + ...((this.fetchOptions as any) ?? {}), + ...((options.fetchOptions as any) ?? {}), + }; + + return { req, url, timeout: options.timeout }; + } + + private async buildHeaders({ + options, + method, + bodyHeaders, + retryCount, + }: { + options: FinalRequestOptions; + method: HTTPMethod; + bodyHeaders: HeadersLike; + retryCount: number; + }): Promise { + let idempotencyHeaders: HeadersLike = {}; + if (this.idempotencyHeader && method !== 'get') { + if (!options.idempotencyKey) options.idempotencyKey = this.defaultIdempotencyKey(); + idempotencyHeaders[this.idempotencyHeader] = options.idempotencyKey; + } + + const headers = buildHeaders([ + idempotencyHeaders, + { + Accept: 'application/json', + 'User-Agent': this.getUserAgent(), + 'X-Stainless-Retry-Count': String(retryCount), + ...(options.timeout ? { 'X-Stainless-Timeout': String(Math.trunc(options.timeout / 1000)) } : {}), + ...getPlatformHeaders(), + }, + await this.authHeaders(options), + this._options.defaultHeaders, + bodyHeaders, + options.headers, + ]); + + this.validateHeaders(headers); + + return headers.values; + } + + private _makeAbort(controller: AbortController) { + // note: we can't just inline this method inside `fetchWithTimeout()` because then the closure + // would capture all request options, and cause a memory leak. + return () => controller.abort(); + } + + private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): { + bodyHeaders: HeadersLike; + body: BodyInit | undefined; + } { + if (!body) { + return { bodyHeaders: undefined, body: undefined }; + } + const headers = buildHeaders([rawHeaders]); + if ( + // Pass raw type verbatim + ArrayBuffer.isView(body) || + body instanceof ArrayBuffer || + body instanceof DataView || + (typeof body === 'string' && + // Preserve legacy string encoding behavior for now + headers.values.has('content-type')) || + // `Blob` is superset of `File` + ((globalThis as any).Blob && body instanceof (globalThis as any).Blob) || + // `FormData` -> `multipart/form-data` + body instanceof FormData || + // `URLSearchParams` -> `application/x-www-form-urlencoded` + body instanceof URLSearchParams || + // Send chunked stream (each chunk has own `length`) + ((globalThis as any).ReadableStream && body instanceof (globalThis as any).ReadableStream) + ) { + return { bodyHeaders: undefined, body: body as BodyInit }; + } else if ( + typeof body === 'object' && + (Symbol.asyncIterator in body || + (Symbol.iterator in body && 'next' in body && typeof body.next === 'function')) + ) { + return { bodyHeaders: undefined, body: Shims.ReadableStreamFrom(body as AsyncIterable) }; + } else if ( + typeof body === 'object' && + headers.values.get('content-type') === 'application/x-www-form-urlencoded' + ) { + return { + bodyHeaders: { 'content-type': 'application/x-www-form-urlencoded' }, + body: this.stringifyQuery(body), + }; + } else { + return this.#encoder({ body, headers }); + } + } + + static Langsmith = this; + static DEFAULT_TIMEOUT = 90000; // 1.5 minutes + + static LangsmithError = Errors.LangsmithError; + static APIError = Errors.APIError; + static APIConnectionError = Errors.APIConnectionError; + static APIConnectionTimeoutError = Errors.APIConnectionTimeoutError; + static APIUserAbortError = Errors.APIUserAbortError; + static NotFoundError = Errors.NotFoundError; + static ConflictError = Errors.ConflictError; + static RateLimitError = Errors.RateLimitError; + static BadRequestError = Errors.BadRequestError; + static AuthenticationError = Errors.AuthenticationError; + static InternalServerError = Errors.InternalServerError; + static PermissionDeniedError = Errors.PermissionDeniedError; + static UnprocessableEntityError = Errors.UnprocessableEntityError; + + static toFile = Uploads.toFile; + + productFeedback: API.ProductFeedback = new API.ProductFeedback(this); + fleet: API.Fleet = new API.Fleet(this); + datasets: API.Datasets = new API.Datasets(this); + runs: API.Runs = new API.Runs(this); + threads: API.Threads = new API.Threads(this); + traces: API.Traces = new API.Traces(this); + onlineEvaluators: API.OnlineEvaluators = new API.OnlineEvaluators(this); + public: API.Public = new API.Public(this); + annotationQueues: API.AnnotationQueues = new API.AnnotationQueues(this); + info: API.Info = new API.Info(this); + issues: API.Issues = new API.Issues(this); + sandboxes: API.Sandboxes = new API.Sandboxes(this); +} + +Langsmith.ProductFeedback = ProductFeedback; +Langsmith.Fleet = Fleet; +Langsmith.Datasets = Datasets; +Langsmith.Runs = Runs; +Langsmith.Threads = Threads; +Langsmith.Traces = Traces; +Langsmith.OnlineEvaluators = OnlineEvaluators; +Langsmith.Public = Public; +Langsmith.AnnotationQueues = AnnotationQueues; +Langsmith.Info = Info; +Langsmith.Issues = Issues; +Langsmith.Sandboxes = Sandboxes; + +export declare namespace Langsmith { + export type RequestOptions = Opts.RequestOptions; + + export import OffsetPaginationTopLevelArray = Pagination.OffsetPaginationTopLevelArray; + export { + type OffsetPaginationTopLevelArrayParams as OffsetPaginationTopLevelArrayParams, + type OffsetPaginationTopLevelArrayResponse as OffsetPaginationTopLevelArrayResponse, + }; + + export import OffsetPaginationIssues = Pagination.OffsetPaginationIssues; + export { + type OffsetPaginationIssuesParams as OffsetPaginationIssuesParams, + type OffsetPaginationIssuesResponse as OffsetPaginationIssuesResponse, + }; + + export import OffsetPaginationRepos = Pagination.OffsetPaginationRepos; + export { + type OffsetPaginationReposParams as OffsetPaginationReposParams, + type OffsetPaginationReposResponse as OffsetPaginationReposResponse, + }; + + export import OffsetPaginationCommits = Pagination.OffsetPaginationCommits; + export { + type OffsetPaginationCommitsParams as OffsetPaginationCommitsParams, + type OffsetPaginationCommitsResponse as OffsetPaginationCommitsResponse, + }; + + export import OffsetPaginationOnlineEvaluators = Pagination.OffsetPaginationOnlineEvaluators; + export { + type OffsetPaginationOnlineEvaluatorsParams as OffsetPaginationOnlineEvaluatorsParams, + type OffsetPaginationOnlineEvaluatorsResponse as OffsetPaginationOnlineEvaluatorsResponse, + }; + + export import OffsetPaginationInsightsClusteringJobs = Pagination.OffsetPaginationInsightsClusteringJobs; + export { + type OffsetPaginationInsightsClusteringJobsParams as OffsetPaginationInsightsClusteringJobsParams, + type OffsetPaginationInsightsClusteringJobsResponse as OffsetPaginationInsightsClusteringJobsResponse, + }; + + export import CursorPagination = Pagination.CursorPagination; + export { + type CursorPaginationParams as CursorPaginationParams, + type CursorPaginationResponse as CursorPaginationResponse, + }; + + export import ItemsCursorPostPagination = Pagination.ItemsCursorPostPagination; + export { + type ItemsCursorPostPaginationParams as ItemsCursorPostPaginationParams, + type ItemsCursorPostPaginationResponse as ItemsCursorPostPaginationResponse, + }; + + export import ItemsCursorGetPagination = Pagination.ItemsCursorGetPagination; + export { + type ItemsCursorGetPaginationParams as ItemsCursorGetPaginationParams, + type ItemsCursorGetPaginationResponse as ItemsCursorGetPaginationResponse, + }; + + export { + ProductFeedback as ProductFeedback, + type ProductFeedbackCreateResponse as ProductFeedbackCreateResponse, + type ProductFeedbackRetrieveResponse as ProductFeedbackRetrieveResponse, + type ProductFeedbackCreateParams as ProductFeedbackCreateParams, + }; + + export { Fleet as Fleet }; + + export { + Datasets as Datasets, + type DataType as DataType, + type Dataset as Dataset, + type DatasetTransformation as DatasetTransformation, + type DatasetVersion as DatasetVersion, + type FeedbackCreateCoreSchema as FeedbackCreateCoreSchema, + type Missing as Missing, + type SortByDatasetColumn as SortByDatasetColumn, + }; + + export { + Runs as Runs, + type ResponseBodyForRunsGenerateQuery as ResponseBodyForRunsGenerateQuery, + type Run as Run, + type RunIngest as RunIngest, + type RunSchema as RunSchema, + type RunSelectField as RunSelectField, + type RunStatsQueryParams as RunStatsQueryParams, + type RunType as RunType, + type RunTypeEnum as RunTypeEnum, + type RunsFilterDataSourceTypeEnum as RunsFilterDataSourceTypeEnum, + type RunGetURLResponse as RunGetURLResponse, + type RunsItemsCursorPostPagination as RunsItemsCursorPostPagination, + type RunGetURLParams as RunGetURLParams, + type RunQueryV2Params as RunQueryV2Params, + type RunRetrieveV2Params as RunRetrieveV2Params, + }; + + export { + Threads as Threads, + type Thread as Thread, + type ThreadStats as ThreadStats, + type ThreadTrace as ThreadTrace, + type ThreadAggregateStatsResponse as ThreadAggregateStatsResponse, + type ThreadTracesItemsCursorGetPagination as ThreadTracesItemsCursorGetPagination, + type ThreadsItemsCursorPostPagination as ThreadsItemsCursorPostPagination, + type ThreadAggregateStatsParams as ThreadAggregateStatsParams, + type ThreadListTracesParams as ThreadListTracesParams, + type ThreadQueryParams as ThreadQueryParams, + type ThreadStatsParams as ThreadStatsParams, + }; + + export { + Traces as Traces, + type Trace as Trace, + type TraceAggregates as TraceAggregates, + type TraceListRunsResponse as TraceListRunsResponse, + type TracesItemsCursorPostPagination as TracesItemsCursorPostPagination, + type TraceListRunsParams as TraceListRunsParams, + type TraceQueryParams as TraceQueryParams, + }; + + export { + OnlineEvaluators as OnlineEvaluators, + type BulkDeleteEvaluatorFailedItem as BulkDeleteEvaluatorFailedItem, + type BulkDeleteEvaluatorsResponse as BulkDeleteEvaluatorsResponse, + type CreateOnlineCodeEvaluatorRequest as CreateOnlineCodeEvaluatorRequest, + type CreateOnlineEvaluatorRequest as CreateOnlineEvaluatorRequest, + type CreateOnlineEvaluatorResponse as CreateOnlineEvaluatorResponse, + type CreateOnlineLlmEvaluatorRequest as CreateOnlineLlmEvaluatorRequest, + type GetOnlineEvaluatorSpendResponse as GetOnlineEvaluatorSpendResponse, + type OnlineCodeEvaluator as OnlineCodeEvaluator, + type OnlineEvaluator as OnlineEvaluator, + type OnlineEvaluatorRunRule as OnlineEvaluatorRunRule, + type OnlineEvaluatorSpendDay as OnlineEvaluatorSpendDay, + type OnlineEvaluatorSpendGroup as OnlineEvaluatorSpendGroup, + type OnlineEvaluatorType as OnlineEvaluatorType, + type OnlineLlmEvaluator as OnlineLlmEvaluator, + type OnlineSpendLimit as OnlineSpendLimit, + type UpdateOnlineCodeEvaluatorRequest as UpdateOnlineCodeEvaluatorRequest, + type UpdateOnlineEvaluatorRequest as UpdateOnlineEvaluatorRequest, + type UpdateOnlineEvaluatorResponse as UpdateOnlineEvaluatorResponse, + type UpdateOnlineLlmEvaluatorRequest as UpdateOnlineLlmEvaluatorRequest, + type OnlineEvaluatorsOffsetPaginationOnlineEvaluators as OnlineEvaluatorsOffsetPaginationOnlineEvaluators, + type OnlineEvaluatorCreateParams as OnlineEvaluatorCreateParams, + type OnlineEvaluatorUpdateParams as OnlineEvaluatorUpdateParams, + type OnlineEvaluatorListParams as OnlineEvaluatorListParams, + type OnlineEvaluatorDeleteParams as OnlineEvaluatorDeleteParams, + type OnlineEvaluatorBulkDeleteParams as OnlineEvaluatorBulkDeleteParams, + type OnlineEvaluatorSpendParams as OnlineEvaluatorSpendParams, + }; + + export { Public as Public }; + + export { + AnnotationQueues as AnnotationQueues, + type AnnotationQueueRubricItemSchema as AnnotationQueueRubricItemSchema, + type AnnotationQueueSchema as AnnotationQueueSchema, + type AnnotationQueueSizeSchema as AnnotationQueueSizeSchema, + type RunSchemaWithAnnotationQueueInfo as RunSchemaWithAnnotationQueueInfo, + type AnnotationQueueRetrieveResponse as AnnotationQueueRetrieveResponse, + type AnnotationQueueUpdateResponse as AnnotationQueueUpdateResponse, + type AnnotationQueueDeleteResponse as AnnotationQueueDeleteResponse, + type AnnotationQueueCreateRunStatusResponse as AnnotationQueueCreateRunStatusResponse, + type AnnotationQueueExportResponse as AnnotationQueueExportResponse, + type AnnotationQueuePopulateResponse as AnnotationQueuePopulateResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponse as AnnotationQueueRetrieveAnnotationQueuesResponse, + type AnnotationQueueRetrieveQueuesResponse as AnnotationQueueRetrieveQueuesResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray as AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, + type AnnotationQueueUpdateParams as AnnotationQueueUpdateParams, + type AnnotationQueueAnnotationQueuesParams as AnnotationQueueAnnotationQueuesParams, + type AnnotationQueueCreateRunStatusParams as AnnotationQueueCreateRunStatusParams, + type AnnotationQueueExportParams as AnnotationQueueExportParams, + type AnnotationQueuePopulateParams as AnnotationQueuePopulateParams, + type AnnotationQueueRetrieveAnnotationQueuesParams as AnnotationQueueRetrieveAnnotationQueuesParams, + type AnnotationQueueRetrieveRunParams as AnnotationQueueRetrieveRunParams, + type AnnotationQueueRetrieveSizeParams as AnnotationQueueRetrieveSizeParams, + type AnnotationQueueRetrieveTotalArchivedParams as AnnotationQueueRetrieveTotalArchivedParams, + }; + + export { Info as Info, type InfoListResponse as InfoListResponse }; + + export { + Issues as Issues, + type Issue as Issue, + type IssuesOffsetPaginationIssues as IssuesOffsetPaginationIssues, + type IssueRetrieveParams as IssueRetrieveParams, + type IssueListParams as IssueListParams, + }; + + export { + Sandboxes as Sandboxes, + type DownloadURLResponse as DownloadURLResponse, + type SandboxListResponse as SandboxListResponse, + type SandboxResponse as SandboxResponse, + type SandboxStatusResponse as SandboxStatusResponse, + type ServiceURLResponse as ServiceURLResponse, + type SnapshotListResponse as SnapshotListResponse, + type SnapshotResponse as SnapshotResponse, + type SandboxListUsageCostsResponse as SandboxListUsageCostsResponse, + type SandboxListUsageCostsResponsesItemsCursorGetPagination as SandboxListUsageCostsResponsesItemsCursorGetPagination, + type SandboxListUsageCostsParams as SandboxListUsageCostsParams, + }; + + export type AgentAddress = API.AgentAddress; +} diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 0000000..485fce8 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,3 @@ +# `core` + +This directory holds public modules implementing non-resource-specific SDK functionality. diff --git a/src/core/api-promise.ts b/src/core/api-promise.ts new file mode 100644 index 0000000..628483c --- /dev/null +++ b/src/core/api-promise.ts @@ -0,0 +1,92 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { type Langsmith } from '../client'; + +import { type PromiseOrValue } from '../internal/types'; +import { APIResponseProps, defaultParseResponse } from '../internal/parse'; + +/** + * A subclass of `Promise` providing additional helper methods + * for interacting with the SDK. + */ +export class APIPromise extends Promise { + private parsedPromise: Promise | undefined; + #client: Langsmith; + + constructor( + client: Langsmith, + private responsePromise: Promise, + private parseResponse: ( + client: Langsmith, + props: APIResponseProps, + ) => PromiseOrValue = defaultParseResponse, + ) { + super((resolve) => { + // this is maybe a bit weird but this has to be a no-op to not implicitly + // parse the response body; instead .then, .catch, .finally are overridden + // to parse the response + resolve(null as any); + }); + this.#client = client; + } + + _thenUnwrap(transform: (data: T, props: APIResponseProps) => U): APIPromise { + return new APIPromise(this.#client, this.responsePromise, async (client, props) => + transform(await this.parseResponse(client, props), props), + ); + } + + /** + * Gets the raw `Response` instance instead of parsing the response + * data. + * + * If you want to parse the response body but still get the `Response` + * instance, you can use {@link withResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + asResponse(): Promise { + return this.responsePromise.then((p) => p.response); + } + + /** + * Gets the parsed response data and the raw `Response` instance. + * + * If you just want to get the raw `Response` instance without parsing it, + * you can use {@link asResponse()}. + * + * 👋 Getting the wrong TypeScript type for `Response`? + * Try setting `"moduleResolution": "NodeNext"` or add `"lib": ["DOM"]` + * to your `tsconfig.json`. + */ + async withResponse(): Promise<{ data: T; response: Response }> { + const [data, response] = await Promise.all([this.parse(), this.asResponse()]); + return { data, response }; + } + + private parse(): Promise { + if (!this.parsedPromise) { + this.parsedPromise = this.responsePromise.then((data) => this.parseResponse(this.#client, data)); + } + return this.parsedPromise; + } + + override then( + onfulfilled?: ((value: T) => TResult1 | PromiseLike) | undefined | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | undefined | null, + ): Promise { + return this.parse().then(onfulfilled, onrejected); + } + + override catch( + onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null, + ): Promise { + return this.parse().catch(onrejected); + } + + override finally(onfinally?: (() => void) | undefined | null): Promise { + return this.parse().finally(onfinally); + } +} diff --git a/src/core/error.ts b/src/core/error.ts new file mode 100644 index 0000000..cc7a420 --- /dev/null +++ b/src/core/error.ts @@ -0,0 +1,130 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { castToError } from '../internal/errors'; + +export class LangsmithError extends Error {} + +export class APIError< + TStatus extends number | undefined = number | undefined, + THeaders extends Headers | undefined = Headers | undefined, + TError extends Object | undefined = Object | undefined, +> extends LangsmithError { + /** HTTP status for the response that caused the error */ + readonly status: TStatus; + /** HTTP headers for the response that caused the error */ + readonly headers: THeaders; + /** JSON body of the response that caused the error */ + readonly error: TError; + + constructor(status: TStatus, error: TError, message: string | undefined, headers: THeaders) { + super(`${APIError.makeMessage(status, error, message)}`); + this.status = status; + this.headers = headers; + this.error = error; + } + + private static makeMessage(status: number | undefined, error: any, message: string | undefined) { + const msg = + error?.message ? + typeof error.message === 'string' ? + error.message + : JSON.stringify(error.message) + : error ? JSON.stringify(error) + : message; + + if (status && msg) { + return `${status} ${msg}`; + } + if (status) { + return `${status} status code (no body)`; + } + if (msg) { + return msg; + } + return '(no status code or body)'; + } + + static generate( + status: number | undefined, + errorResponse: Object | undefined, + message: string | undefined, + headers: Headers | undefined, + ): APIError { + if (!status || !headers) { + return new APIConnectionError({ message, cause: castToError(errorResponse) }); + } + + const error = errorResponse as Record; + + if (status === 400) { + return new BadRequestError(status, error, message, headers); + } + + if (status === 401) { + return new AuthenticationError(status, error, message, headers); + } + + if (status === 403) { + return new PermissionDeniedError(status, error, message, headers); + } + + if (status === 404) { + return new NotFoundError(status, error, message, headers); + } + + if (status === 409) { + return new ConflictError(status, error, message, headers); + } + + if (status === 422) { + return new UnprocessableEntityError(status, error, message, headers); + } + + if (status === 429) { + return new RateLimitError(status, error, message, headers); + } + + if (status >= 500) { + return new InternalServerError(status, error, message, headers); + } + + return new APIError(status, error, message, headers); + } +} + +export class APIUserAbortError extends APIError { + constructor({ message }: { message?: string } = {}) { + super(undefined, undefined, message || 'Request was aborted.', undefined); + } +} + +export class APIConnectionError extends APIError { + constructor({ message, cause }: { message?: string | undefined; cause?: Error | undefined }) { + super(undefined, undefined, message || 'Connection error.', undefined); + // in some environments the 'cause' property is already declared + // @ts-ignore + if (cause) this.cause = cause; + } +} + +export class APIConnectionTimeoutError extends APIConnectionError { + constructor({ message }: { message?: string } = {}) { + super({ message: message ?? 'Request timed out.' }); + } +} + +export class BadRequestError extends APIError<400, Headers> {} + +export class AuthenticationError extends APIError<401, Headers> {} + +export class PermissionDeniedError extends APIError<403, Headers> {} + +export class NotFoundError extends APIError<404, Headers> {} + +export class ConflictError extends APIError<409, Headers> {} + +export class UnprocessableEntityError extends APIError<422, Headers> {} + +export class RateLimitError extends APIError<429, Headers> {} + +export class InternalServerError extends APIError {} diff --git a/src/core/pagination.ts b/src/core/pagination.ts new file mode 100644 index 0000000..6ef6130 --- /dev/null +++ b/src/core/pagination.ts @@ -0,0 +1,548 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { LangsmithError } from './error'; +import { FinalRequestOptions } from '../internal/request-options'; +import { defaultParseResponse } from '../internal/parse'; +import { type Langsmith } from '../client'; +import { APIPromise } from './api-promise'; +import { type APIResponseProps } from '../internal/parse'; +import { maybeObj } from '../internal/utils/values'; + +export type PageRequestOptions = Pick; + +export abstract class AbstractPage implements AsyncIterable { + #client: Langsmith; + protected options: FinalRequestOptions; + + protected response: Response; + protected body: unknown; + + constructor(client: Langsmith, response: Response, body: unknown, options: FinalRequestOptions) { + this.#client = client; + this.options = options; + this.response = response; + this.body = body; + } + + abstract nextPageRequestOptions(): PageRequestOptions | null; + + abstract getPaginatedItems(): Item[]; + + hasNextPage(): boolean { + const items = this.getPaginatedItems(); + if (!items.length) return false; + return this.nextPageRequestOptions() != null; + } + + async getNextPage(): Promise { + const nextOptions = this.nextPageRequestOptions(); + if (!nextOptions) { + throw new LangsmithError( + 'No next page expected; please check `.hasNextPage()` before calling `.getNextPage()`.', + ); + } + + return await this.#client.requestAPIList(this.constructor as any, nextOptions); + } + + async *iterPages(): AsyncGenerator { + let page: this = this; + yield page; + while (page.hasNextPage()) { + page = await page.getNextPage(); + yield page; + } + } + + async *[Symbol.asyncIterator](): AsyncGenerator { + for await (const page of this.iterPages()) { + for (const item of page.getPaginatedItems()) { + yield item; + } + } + } +} + +/** + * This subclass of Promise will resolve to an instantiated Page once the request completes. + * + * It also implements AsyncIterable to allow auto-paginating iteration on an unawaited list call, eg: + * + * for await (const item of client.items.list()) { + * console.log(item) + * } + */ +export class PagePromise< + PageClass extends AbstractPage, + Item = ReturnType[number], + > + extends APIPromise + implements AsyncIterable +{ + constructor( + client: Langsmith, + request: Promise, + Page: new (...args: ConstructorParameters) => PageClass, + ) { + super( + client, + request, + async (client, props) => + new Page(client, props.response, await defaultParseResponse(client, props), props.options), + ); + } + + /** + * Allow auto-paginating iteration on an unawaited list call, eg: + * + * for await (const item of client.items.list()) { + * console.log(item) + * } + */ + async *[Symbol.asyncIterator](): AsyncGenerator { + const page = await this; + for await (const item of page) { + yield item; + } + } +} + +export type OffsetPaginationTopLevelArrayResponse = Item[]; + +export interface OffsetPaginationTopLevelArrayParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationTopLevelArray extends AbstractPage { + items: Array; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationTopLevelArrayResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.items = body || []; + } + + getPaginatedItems(): Item[] { + return this.items ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationTopLevelArrayParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export type OffsetPaginationIssuesResponse = Item[]; + +export interface OffsetPaginationIssuesParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationIssues extends AbstractPage { + items: Array; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationIssuesResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.items = body || []; + } + + getPaginatedItems(): Item[] { + return this.items ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationIssuesParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export interface OffsetPaginationReposResponse { + repos: Array; + + total: number; +} + +export interface OffsetPaginationReposParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationRepos + extends AbstractPage + implements OffsetPaginationReposResponse +{ + repos: Array; + + total: number; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationReposResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.repos = body.repos || []; + this.total = body.total || 0; + } + + getPaginatedItems(): Item[] { + return this.repos ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationReposParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export interface OffsetPaginationCommitsResponse { + commits: Array; + + total: number; +} + +export interface OffsetPaginationCommitsParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationCommits + extends AbstractPage + implements OffsetPaginationCommitsResponse +{ + commits: Array; + + total: number; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationCommitsResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.commits = body.commits || []; + this.total = body.total || 0; + } + + getPaginatedItems(): Item[] { + return this.commits ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationCommitsParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export interface OffsetPaginationOnlineEvaluatorsResponse { + evaluators: Array; + + total: number; +} + +export interface OffsetPaginationOnlineEvaluatorsParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationOnlineEvaluators + extends AbstractPage + implements OffsetPaginationOnlineEvaluatorsResponse +{ + evaluators: Array; + + total: number; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationOnlineEvaluatorsResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.evaluators = body.evaluators || []; + this.total = body.total || 0; + } + + getPaginatedItems(): Item[] { + return this.evaluators ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationOnlineEvaluatorsParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export interface OffsetPaginationInsightsClusteringJobsResponse { + clustering_jobs: Array; +} + +export interface OffsetPaginationInsightsClusteringJobsParams { + offset?: number; + + limit?: number; +} + +export class OffsetPaginationInsightsClusteringJobs + extends AbstractPage + implements OffsetPaginationInsightsClusteringJobsResponse +{ + clustering_jobs: Array; + + constructor( + client: Langsmith, + response: Response, + body: OffsetPaginationInsightsClusteringJobsResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.clustering_jobs = body.clustering_jobs || []; + } + + getPaginatedItems(): Item[] { + return this.clustering_jobs ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const offset = (this.options.query as OffsetPaginationInsightsClusteringJobsParams).offset ?? 0; + const length = this.getPaginatedItems().length; + const currentCount = offset + length; + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + offset: currentCount, + }, + }; + } +} + +export interface CursorPaginationResponse { + runs: Array; + + cursors: CursorPaginationResponse.Cursors; +} + +export namespace CursorPaginationResponse { + export interface Cursors { + next?: string; + } +} + +export interface CursorPaginationParams { + cursor?: string; + + limit?: number; +} + +export class CursorPagination extends AbstractPage implements CursorPaginationResponse { + runs: Array; + + cursors: CursorPaginationResponse.Cursors; + + constructor( + client: Langsmith, + response: Response, + body: CursorPaginationResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.runs = body.runs || []; + this.cursors = body.cursors || {}; + } + + getPaginatedItems(): Item[] { + return this.runs ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const cursor = this.cursors?.next; + if (!cursor) { + return null; + } + + return { + ...this.options, + body: { + ...maybeObj(this.options.body), + cursor, + }, + }; + } +} + +export interface ItemsCursorPostPaginationResponse { + items: Array; + + next_cursor: string; +} + +export interface ItemsCursorPostPaginationParams { + cursor?: string; + + page_size?: number; +} + +export class ItemsCursorPostPagination + extends AbstractPage + implements ItemsCursorPostPaginationResponse +{ + items: Array; + + next_cursor: string; + + constructor( + client: Langsmith, + response: Response, + body: ItemsCursorPostPaginationResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.items = body.items || []; + this.next_cursor = body.next_cursor || ''; + } + + getPaginatedItems(): Item[] { + return this.items ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const cursor = this.next_cursor; + if (!cursor) { + return null; + } + + return { + ...this.options, + body: { + ...maybeObj(this.options.body), + cursor, + }, + }; + } +} + +export interface ItemsCursorGetPaginationResponse { + items: Array; + + next_cursor: string; +} + +export interface ItemsCursorGetPaginationParams { + cursor?: string; + + page_size?: number; +} + +export class ItemsCursorGetPagination + extends AbstractPage + implements ItemsCursorGetPaginationResponse +{ + items: Array; + + next_cursor: string; + + constructor( + client: Langsmith, + response: Response, + body: ItemsCursorGetPaginationResponse, + options: FinalRequestOptions, + ) { + super(client, response, body, options); + + this.items = body.items || []; + this.next_cursor = body.next_cursor || ''; + } + + getPaginatedItems(): Item[] { + return this.items ?? []; + } + + nextPageRequestOptions(): PageRequestOptions | null { + const cursor = this.next_cursor; + if (!cursor) { + return null; + } + + return { + ...this.options, + query: { + ...maybeObj(this.options.query), + cursor, + }, + }; + } +} diff --git a/src/core/resource.ts b/src/core/resource.ts new file mode 100644 index 0000000..2b3350e --- /dev/null +++ b/src/core/resource.ts @@ -0,0 +1,11 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { Langsmith } from '../client'; + +export abstract class APIResource { + protected _client: Langsmith; + + constructor(client: Langsmith) { + this._client = client; + } +} diff --git a/src/core/uploads.ts b/src/core/uploads.ts new file mode 100644 index 0000000..2882ca6 --- /dev/null +++ b/src/core/uploads.ts @@ -0,0 +1,2 @@ +export { type Uploadable } from '../internal/uploads'; +export { toFile, type ToFileInput } from '../internal/to-file'; diff --git a/src/error.ts b/src/error.ts new file mode 100644 index 0000000..fc55f46 --- /dev/null +++ b/src/error.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/error instead */ +export * from './core/error'; diff --git a/src/evaluation.ts b/src/evaluation.ts new file mode 100644 index 0000000..7b2512b --- /dev/null +++ b/src/evaluation.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/evaluation/index'; diff --git a/src/experimental/anthropic.ts b/src/experimental/anthropic.ts new file mode 100644 index 0000000..a87adab --- /dev/null +++ b/src/experimental/anthropic.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/experimental/anthropic/index'; diff --git a/src/experimental/otel/exporter.ts b/src/experimental/otel/exporter.ts new file mode 100644 index 0000000..f493550 --- /dev/null +++ b/src/experimental/otel/exporter.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../../lib/experimental/otel/exporter'; diff --git a/src/experimental/otel/processor.ts b/src/experimental/otel/processor.ts new file mode 100644 index 0000000..3668fca --- /dev/null +++ b/src/experimental/otel/processor.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../../lib/experimental/otel/processor'; diff --git a/src/experimental/otel/setup.ts b/src/experimental/otel/setup.ts new file mode 100644 index 0000000..1828041 --- /dev/null +++ b/src/experimental/otel/setup.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../../lib/experimental/otel/setup'; diff --git a/src/experimental/sandbox.ts b/src/experimental/sandbox.ts new file mode 100644 index 0000000..b1149b9 --- /dev/null +++ b/src/experimental/sandbox.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/sandbox/index'; diff --git a/src/experimental/vercel.ts b/src/experimental/vercel.ts new file mode 100644 index 0000000..fd04436 --- /dev/null +++ b/src/experimental/vercel.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/experimental/vercel/index'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..267a11d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { Langsmith as default } from './client'; + +export { type Uploadable, toFile } from './core/uploads'; +export { APIPromise } from './core/api-promise'; +export { Langsmith, type ClientOptions } from './client'; +export { PagePromise } from './core/pagination'; +export { + LangsmithError, + APIError, + APIConnectionError, + APIConnectionTimeoutError, + APIUserAbortError, + NotFoundError, + ConflictError, + RateLimitError, + BadRequestError, + AuthenticationError, + InternalServerError, + PermissionDeniedError, + UnprocessableEntityError, +} from './core/error'; + +// Hand-written SDK surface (tracing Client, RunTree, traceable, ...). Lives in src/lib. +export * from './lib/index'; diff --git a/src/internal/README.md b/src/internal/README.md new file mode 100644 index 0000000..3ef5a25 --- /dev/null +++ b/src/internal/README.md @@ -0,0 +1,3 @@ +# `internal` + +The modules in this directory are not importable outside this package and will change between releases. diff --git a/src/internal/builtin-types.ts b/src/internal/builtin-types.ts new file mode 100644 index 0000000..c23d3bd --- /dev/null +++ b/src/internal/builtin-types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type Fetch = (input: string | URL | Request, init?: RequestInit) => Promise; + +/** + * An alias to the builtin `RequestInit` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit + */ +type _RequestInit = RequestInit; + +/** + * An alias to the builtin `Response` type so we can + * easily alias it in import statements if there are name clashes. + * + * https://developer.mozilla.org/docs/Web/API/Response + */ +type _Response = Response; + +/** + * The type for the first argument to `fetch`. + * + * https://developer.mozilla.org/docs/Web/API/Window/fetch#resource + */ +type _RequestInfo = Request | URL | string; + +/** + * The type for constructing `RequestInit` Headers. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#setting_headers + */ +type _HeadersInit = RequestInit['headers']; + +/** + * The type for constructing `RequestInit` body. + * + * https://developer.mozilla.org/docs/Web/API/RequestInit#body + */ +type _BodyInit = RequestInit['body']; + +/** + * An alias to the builtin `Array` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Array = Array; + +/** + * An alias to the builtin `Record` type so we can + * easily alias it in import statements if there are name clashes. + */ +type _Record = Record; + +export type { + _Array as Array, + _BodyInit as BodyInit, + _HeadersInit as HeadersInit, + _Record as Record, + _RequestInfo as RequestInfo, + _RequestInit as RequestInit, + _Response as Response, +}; + +/** + * A copy of the builtin `EndingType` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L27941 + */ +type EndingType = 'native' | 'transparent'; + +/** + * A copy of the builtin `BlobPropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L154 + * https://developer.mozilla.org/en-US/docs/Web/API/Blob/Blob#options + */ +export interface BlobPropertyBag { + endings?: EndingType; + type?: string; +} + +/** + * A copy of the builtin `FilePropertyBag` type as it isn't fully supported in certain + * environments and attempting to reference the global version will error. + * + * https://github.com/microsoft/TypeScript/blob/49ad1a3917a0ea57f5ff248159256e12bb1cb705/src/lib/dom.generated.d.ts#L503 + * https://developer.mozilla.org/en-US/docs/Web/API/File/File#options + */ +export interface FilePropertyBag extends BlobPropertyBag { + lastModified?: number; +} diff --git a/src/internal/detect-platform.ts b/src/internal/detect-platform.ts new file mode 100644 index 0000000..e82d95c --- /dev/null +++ b/src/internal/detect-platform.ts @@ -0,0 +1,196 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { VERSION } from '../version'; + +export const isRunningInBrowser = () => { + return ( + // @ts-ignore + typeof window !== 'undefined' && + // @ts-ignore + typeof window.document !== 'undefined' && + // @ts-ignore + typeof navigator !== 'undefined' + ); +}; + +type DetectedPlatform = 'deno' | 'node' | 'edge' | 'unknown'; + +/** + * Note this does not detect 'browser'; for that, use getBrowserInfo(). + */ +function getDetectedPlatform(): DetectedPlatform { + if (typeof Deno !== 'undefined' && Deno.build != null) { + return 'deno'; + } + if (typeof EdgeRuntime !== 'undefined') { + return 'edge'; + } + if ( + Object.prototype.toString.call( + typeof (globalThis as any).process !== 'undefined' ? (globalThis as any).process : 0, + ) === '[object process]' + ) { + return 'node'; + } + return 'unknown'; +} + +declare const Deno: any; +declare const EdgeRuntime: any; +type Arch = 'x32' | 'x64' | 'arm' | 'arm64' | `other:${string}` | 'unknown'; +type PlatformName = + | 'MacOS' + | 'Linux' + | 'Windows' + | 'FreeBSD' + | 'OpenBSD' + | 'iOS' + | 'Android' + | `Other:${string}` + | 'Unknown'; +type Browser = 'ie' | 'edge' | 'chrome' | 'firefox' | 'safari'; +type PlatformProperties = { + 'X-Stainless-Lang': 'js'; + 'X-Stainless-Package-Version': string; + 'X-Stainless-OS': PlatformName; + 'X-Stainless-Arch': Arch; + 'X-Stainless-Runtime': 'node' | 'deno' | 'edge' | `browser:${Browser}` | 'unknown'; + 'X-Stainless-Runtime-Version': string; +}; +const getPlatformProperties = (): PlatformProperties => { + const detectedPlatform = getDetectedPlatform(); + if (detectedPlatform === 'deno') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform(Deno.build.os), + 'X-Stainless-Arch': normalizeArch(Deno.build.arch), + 'X-Stainless-Runtime': 'deno', + 'X-Stainless-Runtime-Version': + typeof Deno.version === 'string' ? Deno.version : Deno.version?.deno ?? 'unknown', + }; + } + if (typeof EdgeRuntime !== 'undefined') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': `other:${EdgeRuntime}`, + 'X-Stainless-Runtime': 'edge', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version, + }; + } + // Check if Node.js + if (detectedPlatform === 'node') { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': normalizePlatform((globalThis as any).process.platform ?? 'unknown'), + 'X-Stainless-Arch': normalizeArch((globalThis as any).process.arch ?? 'unknown'), + 'X-Stainless-Runtime': 'node', + 'X-Stainless-Runtime-Version': (globalThis as any).process.version ?? 'unknown', + }; + } + + const browserInfo = getBrowserInfo(); + if (browserInfo) { + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': `browser:${browserInfo.browser}`, + 'X-Stainless-Runtime-Version': browserInfo.version, + }; + } + + // TODO add support for Cloudflare workers, etc. + return { + 'X-Stainless-Lang': 'js', + 'X-Stainless-Package-Version': VERSION, + 'X-Stainless-OS': 'Unknown', + 'X-Stainless-Arch': 'unknown', + 'X-Stainless-Runtime': 'unknown', + 'X-Stainless-Runtime-Version': 'unknown', + }; +}; + +type BrowserInfo = { + browser: Browser; + version: string; +}; + +declare const navigator: { userAgent: string } | undefined; + +// Note: modified from https://github.com/JS-DevTools/host-environment/blob/b1ab79ecde37db5d6e163c050e54fe7d287d7c92/src/isomorphic.browser.ts +function getBrowserInfo(): BrowserInfo | null { + if (typeof navigator === 'undefined' || !navigator) { + return null; + } + + // NOTE: The order matters here! + const browserPatterns = [ + { key: 'edge' as const, pattern: /Edge(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /MSIE(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'ie' as const, pattern: /Trident(?:.*rv\:(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'chrome' as const, pattern: /Chrome(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'firefox' as const, pattern: /Firefox(?:\W+(\d+)\.(\d+)(?:\.(\d+))?)?/ }, + { key: 'safari' as const, pattern: /(?:Version\W+(\d+)\.(\d+)(?:\.(\d+))?)?(?:\W+Mobile\S*)?\W+Safari/ }, + ]; + + // Find the FIRST matching browser + for (const { key, pattern } of browserPatterns) { + const match = pattern.exec(navigator.userAgent); + if (match) { + const major = match[1] || 0; + const minor = match[2] || 0; + const patch = match[3] || 0; + + return { browser: key, version: `${major}.${minor}.${patch}` }; + } + } + + return null; +} + +const normalizeArch = (arch: string): Arch => { + // Node docs: + // - https://nodejs.org/api/process.html#processarch + // Deno docs: + // - https://doc.deno.land/deno/stable/~/Deno.build + if (arch === 'x32') return 'x32'; + if (arch === 'x86_64' || arch === 'x64') return 'x64'; + if (arch === 'arm') return 'arm'; + if (arch === 'aarch64' || arch === 'arm64') return 'arm64'; + if (arch) return `other:${arch}`; + return 'unknown'; +}; + +const normalizePlatform = (platform: string): PlatformName => { + // Node platforms: + // - https://nodejs.org/api/process.html#processplatform + // Deno platforms: + // - https://doc.deno.land/deno/stable/~/Deno.build + // - https://github.com/denoland/deno/issues/14799 + + platform = platform.toLowerCase(); + + // NOTE: this iOS check is untested and may not work + // Node does not work natively on IOS, there is a fork at + // https://github.com/nodejs-mobile/nodejs-mobile + // however it is unknown at the time of writing how to detect if it is running + if (platform.includes('ios')) return 'iOS'; + if (platform === 'android') return 'Android'; + if (platform === 'darwin') return 'MacOS'; + if (platform === 'win32') return 'Windows'; + if (platform === 'freebsd') return 'FreeBSD'; + if (platform === 'openbsd') return 'OpenBSD'; + if (platform === 'linux') return 'Linux'; + if (platform) return `Other:${platform}`; + return 'Unknown'; +}; + +let _platformHeaders: PlatformProperties; +export const getPlatformHeaders = () => { + return (_platformHeaders ??= getPlatformProperties()); +}; diff --git a/src/internal/errors.ts b/src/internal/errors.ts new file mode 100644 index 0000000..82c7b14 --- /dev/null +++ b/src/internal/errors.ts @@ -0,0 +1,33 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export function isAbortError(err: unknown) { + return ( + typeof err === 'object' && + err !== null && + // Spec-compliant fetch implementations + (('name' in err && (err as any).name === 'AbortError') || + // Expo fetch + ('message' in err && String((err as any).message).includes('FetchRequestCanceledException'))) + ); +} + +export const castToError = (err: any): Error => { + if (err instanceof Error) return err; + if (typeof err === 'object' && err !== null) { + try { + if (Object.prototype.toString.call(err) === '[object Error]') { + // @ts-ignore - not all envs have native support for cause yet + const error = new Error(err.message, err.cause ? { cause: err.cause } : {}); + if (err.stack) error.stack = err.stack; + // @ts-ignore - not all envs have native support for cause yet + if (err.cause && !error.cause) error.cause = err.cause; + if (err.name) error.name = err.name; + return error; + } + } catch {} + try { + return new Error(JSON.stringify(err)); + } catch {} + } + return new Error(err); +}; diff --git a/src/internal/headers.ts b/src/internal/headers.ts new file mode 100644 index 0000000..c724a9d --- /dev/null +++ b/src/internal/headers.ts @@ -0,0 +1,97 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { isReadonlyArray } from './utils/values'; + +type HeaderValue = string | undefined | null; +export type HeadersLike = + | Headers + | readonly HeaderValue[][] + | Record + | undefined + | null + | NullableHeaders; + +const brand_privateNullableHeaders = /* @__PURE__ */ Symbol('brand.privateNullableHeaders'); + +/** + * @internal + * Users can pass explicit nulls to unset default headers. When we parse them + * into a standard headers type we need to preserve that information. + */ +export type NullableHeaders = { + /** Brand check, prevent users from creating a NullableHeaders. */ + [brand_privateNullableHeaders]: true; + /** Parsed headers. */ + values: Headers; + /** Set of lowercase header names explicitly set to null. */ + nulls: Set; +}; + +function* iterateHeaders(headers: HeadersLike): IterableIterator { + if (!headers) return; + + if (brand_privateNullableHeaders in headers) { + const { values, nulls } = headers; + yield* values.entries(); + for (const name of nulls) { + yield [name, null]; + } + return; + } + + let shouldClear = false; + let iter: Iterable; + if (headers instanceof Headers) { + iter = headers.entries(); + } else if (isReadonlyArray(headers)) { + iter = headers; + } else { + shouldClear = true; + iter = Object.entries(headers ?? {}); + } + for (let row of iter) { + const name = row[0]; + if (typeof name !== 'string') throw new TypeError('expected header name to be a string'); + const values = isReadonlyArray(row[1]) ? row[1] : [row[1]]; + let didClear = false; + for (const value of values) { + if (value === undefined) continue; + + // Objects keys always overwrite older headers, they never append. + // Yield a null to clear the header before adding the new values. + if (shouldClear && !didClear) { + didClear = true; + yield [name, null]; + } + yield [name, value]; + } + } +} + +export const buildHeaders = (newHeaders: HeadersLike[]): NullableHeaders => { + const targetHeaders = new Headers(); + const nullHeaders = new Set(); + for (const headers of newHeaders) { + const seenHeaders = new Set(); + for (const [name, value] of iterateHeaders(headers)) { + const lowerName = name.toLowerCase(); + if (!seenHeaders.has(lowerName)) { + targetHeaders.delete(name); + seenHeaders.add(lowerName); + } + if (value === null) { + targetHeaders.delete(name); + nullHeaders.add(lowerName); + } else { + targetHeaders.append(name, value); + nullHeaders.delete(lowerName); + } + } + } + return { [brand_privateNullableHeaders]: true, values: targetHeaders, nulls: nullHeaders }; +}; + +export const isEmptyHeaders = (headers: HeadersLike) => { + for (const _ of iterateHeaders(headers)) return false; + return true; +}; diff --git a/src/internal/parse.ts b/src/internal/parse.ts new file mode 100644 index 0000000..f1c5547 --- /dev/null +++ b/src/internal/parse.ts @@ -0,0 +1,56 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import type { FinalRequestOptions } from './request-options'; +import { type Langsmith } from '../client'; +import { formatRequestDetails, loggerFor } from './utils/log'; + +export type APIResponseProps = { + response: Response; + options: FinalRequestOptions; + controller: AbortController; + requestLogID: string; + retryOfRequestLogID: string | undefined; + startTime: number; +}; + +export async function defaultParseResponse(client: Langsmith, props: APIResponseProps): Promise { + const { response, requestLogID, retryOfRequestLogID, startTime } = props; + const body = await (async () => { + // fetch refuses to read the body when the status code is 204. + if (response.status === 204) { + return null as T; + } + + if (props.options.__binaryResponse) { + return response as unknown as T; + } + + const contentType = response.headers.get('content-type'); + const mediaType = contentType?.split(';')[0]?.trim(); + const isJSON = mediaType?.includes('application/json') || mediaType?.endsWith('+json'); + if (isJSON) { + const contentLength = response.headers.get('content-length'); + if (contentLength === '0') { + // if there is no content we can't do anything + return undefined as T; + } + + const json = await response.json(); + return json as T; + } + + const text = await response.text(); + return text as unknown as T; + })(); + loggerFor(client).debug( + `[${requestLogID}] response parsed`, + formatRequestDetails({ + retryOfRequestLogID, + url: response.url, + status: response.status, + body, + durationMs: Date.now() - startTime, + }), + ); + return body; +} diff --git a/src/internal/qs/LICENSE.md b/src/internal/qs/LICENSE.md new file mode 100644 index 0000000..3fda157 --- /dev/null +++ b/src/internal/qs/LICENSE.md @@ -0,0 +1,13 @@ +BSD 3-Clause License + +Copyright (c) 2014, Nathan LaFreniere and other [contributors](https://github.com/puruvj/neoqs/graphs/contributors) All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/internal/qs/README.md b/src/internal/qs/README.md new file mode 100644 index 0000000..67ae04e --- /dev/null +++ b/src/internal/qs/README.md @@ -0,0 +1,3 @@ +# qs + +This is a vendored version of [neoqs](https://github.com/PuruVJ/neoqs) which is a TypeScript rewrite of [qs](https://github.com/ljharb/qs), a query string library. diff --git a/src/internal/qs/formats.ts b/src/internal/qs/formats.ts new file mode 100644 index 0000000..e76a742 --- /dev/null +++ b/src/internal/qs/formats.ts @@ -0,0 +1,10 @@ +import type { Format } from './types'; + +export const default_format: Format = 'RFC3986'; +export const default_formatter = (v: PropertyKey) => String(v); +export const formatters: Record string> = { + RFC1738: (v: PropertyKey) => String(v).replace(/%20/g, '+'), + RFC3986: default_formatter, +}; +export const RFC1738 = 'RFC1738'; +export const RFC3986 = 'RFC3986'; diff --git a/src/internal/qs/index.ts b/src/internal/qs/index.ts new file mode 100644 index 0000000..c3a3620 --- /dev/null +++ b/src/internal/qs/index.ts @@ -0,0 +1,13 @@ +import { default_format, formatters, RFC1738, RFC3986 } from './formats'; + +const formats = { + formatters, + RFC1738, + RFC3986, + default: default_format, +}; + +export { stringify } from './stringify'; +export { formats }; + +export type { DefaultDecoder, DefaultEncoder, Format, ParseOptions, StringifyOptions } from './types'; diff --git a/src/internal/qs/stringify.ts b/src/internal/qs/stringify.ts new file mode 100644 index 0000000..7e71387 --- /dev/null +++ b/src/internal/qs/stringify.ts @@ -0,0 +1,385 @@ +import { encode, is_buffer, maybe_map, has } from './utils'; +import { default_format, default_formatter, formatters } from './formats'; +import type { NonNullableProperties, StringifyOptions } from './types'; +import { isArray } from '../utils/values'; + +const array_prefix_generators = { + brackets(prefix: PropertyKey) { + return String(prefix) + '[]'; + }, + comma: 'comma', + indices(prefix: PropertyKey, key: string) { + return String(prefix) + '[' + key + ']'; + }, + repeat(prefix: PropertyKey) { + return String(prefix); + }, +}; + +const push_to_array = function (arr: any[], value_or_array: any) { + Array.prototype.push.apply(arr, isArray(value_or_array) ? value_or_array : [value_or_array]); +}; + +let toISOString; + +const defaults = { + addQueryPrefix: false, + allowDots: false, + allowEmptyArrays: false, + arrayFormat: 'indices', + charset: 'utf-8', + charsetSentinel: false, + delimiter: '&', + encode: true, + encodeDotInKeys: false, + encoder: encode, + encodeValuesOnly: false, + format: default_format, + formatter: default_formatter, + /** @deprecated */ + indices: false, + serializeDate(date) { + return (toISOString ??= Function.prototype.call.bind(Date.prototype.toISOString))(date); + }, + skipNulls: false, + strictNullHandling: false, +} as NonNullableProperties; + +function is_non_nullish_primitive(v: unknown): v is string | number | boolean | symbol | bigint { + return ( + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' || + typeof v === 'symbol' || + typeof v === 'bigint' + ); +} + +const sentinel = {}; + +function inner_stringify( + object: any, + prefix: PropertyKey, + generateArrayPrefix: StringifyOptions['arrayFormat'] | ((prefix: string, key: string) => string), + commaRoundTrip: boolean, + allowEmptyArrays: boolean, + strictNullHandling: boolean, + skipNulls: boolean, + encodeDotInKeys: boolean, + encoder: StringifyOptions['encoder'], + filter: StringifyOptions['filter'], + sort: StringifyOptions['sort'], + allowDots: StringifyOptions['allowDots'], + serializeDate: StringifyOptions['serializeDate'], + format: StringifyOptions['format'], + formatter: StringifyOptions['formatter'], + encodeValuesOnly: boolean, + charset: StringifyOptions['charset'], + sideChannel: WeakMap, +) { + let obj = object; + + let tmp_sc = sideChannel; + let step = 0; + let find_flag = false; + while ((tmp_sc = tmp_sc.get(sentinel)) !== void undefined && !find_flag) { + // Where object last appeared in the ref tree + const pos = tmp_sc.get(object); + step += 1; + if (typeof pos !== 'undefined') { + if (pos === step) { + throw new RangeError('Cyclic object value'); + } else { + find_flag = true; // Break while + } + } + if (typeof tmp_sc.get(sentinel) === 'undefined') { + step = 0; + } + } + + if (typeof filter === 'function') { + obj = filter(prefix, obj); + } else if (obj instanceof Date) { + obj = serializeDate?.(obj); + } else if (generateArrayPrefix === 'comma' && isArray(obj)) { + obj = maybe_map(obj, function (value) { + if (value instanceof Date) { + return serializeDate?.(value); + } + return value; + }); + } + + if (obj === null) { + if (strictNullHandling) { + return encoder && !encodeValuesOnly ? + // @ts-expect-error + encoder(prefix, defaults.encoder, charset, 'key', format) + : prefix; + } + + obj = ''; + } + + if (is_non_nullish_primitive(obj) || is_buffer(obj)) { + if (encoder) { + const key_value = + encodeValuesOnly ? prefix + // @ts-expect-error + : encoder(prefix, defaults.encoder, charset, 'key', format); + return [ + formatter?.(key_value) + + '=' + + // @ts-expect-error + formatter?.(encoder(obj, defaults.encoder, charset, 'value', format)), + ]; + } + return [formatter?.(prefix) + '=' + formatter?.(String(obj))]; + } + + const values: string[] = []; + + if (typeof obj === 'undefined') { + return values; + } + + let obj_keys; + if (generateArrayPrefix === 'comma' && isArray(obj)) { + // we need to join elements in + if (encodeValuesOnly && encoder) { + // @ts-expect-error values only + obj = maybe_map(obj, encoder); + } + obj_keys = [{ value: obj.length > 0 ? obj.join(',') || null : void undefined }]; + } else if (isArray(filter)) { + obj_keys = filter; + } else { + const keys = Object.keys(obj); + obj_keys = sort ? keys.sort(sort) : keys; + } + + const encoded_prefix = encodeDotInKeys ? String(prefix).replace(/\./g, '%2E') : String(prefix); + + const adjusted_prefix = + commaRoundTrip && isArray(obj) && obj.length === 1 ? encoded_prefix + '[]' : encoded_prefix; + + if (allowEmptyArrays && isArray(obj) && obj.length === 0) { + return adjusted_prefix + '[]'; + } + + for (let j = 0; j < obj_keys.length; ++j) { + const key = obj_keys[j]; + const value = + // @ts-ignore + typeof key === 'object' && typeof key.value !== 'undefined' ? key.value : obj[key as any]; + + if (skipNulls && value === null) { + continue; + } + + // @ts-ignore + const encoded_key = allowDots && encodeDotInKeys ? (key as any).replace(/\./g, '%2E') : key; + const key_prefix = + isArray(obj) ? + typeof generateArrayPrefix === 'function' ? + generateArrayPrefix(adjusted_prefix, encoded_key) + : adjusted_prefix + : adjusted_prefix + (allowDots ? '.' + encoded_key : '[' + encoded_key + ']'); + + sideChannel.set(object, step); + const valueSideChannel = new WeakMap(); + valueSideChannel.set(sentinel, sideChannel); + push_to_array( + values, + inner_stringify( + value, + key_prefix, + generateArrayPrefix, + commaRoundTrip, + allowEmptyArrays, + strictNullHandling, + skipNulls, + encodeDotInKeys, + // @ts-ignore + generateArrayPrefix === 'comma' && encodeValuesOnly && isArray(obj) ? null : encoder, + filter, + sort, + allowDots, + serializeDate, + format, + formatter, + encodeValuesOnly, + charset, + valueSideChannel, + ), + ); + } + + return values; +} + +function normalize_stringify_options( + opts: StringifyOptions = defaults, +): NonNullableProperties> & { indices?: boolean } { + if (typeof opts.allowEmptyArrays !== 'undefined' && typeof opts.allowEmptyArrays !== 'boolean') { + throw new TypeError('`allowEmptyArrays` option can only be `true` or `false`, when provided'); + } + + if (typeof opts.encodeDotInKeys !== 'undefined' && typeof opts.encodeDotInKeys !== 'boolean') { + throw new TypeError('`encodeDotInKeys` option can only be `true` or `false`, when provided'); + } + + if (opts.encoder !== null && typeof opts.encoder !== 'undefined' && typeof opts.encoder !== 'function') { + throw new TypeError('Encoder has to be a function.'); + } + + const charset = opts.charset || defaults.charset; + if (typeof opts.charset !== 'undefined' && opts.charset !== 'utf-8' && opts.charset !== 'iso-8859-1') { + throw new TypeError('The charset option must be either utf-8, iso-8859-1, or undefined'); + } + + let format = default_format; + if (typeof opts.format !== 'undefined') { + if (!has(formatters, opts.format)) { + throw new TypeError('Unknown format option provided.'); + } + format = opts.format; + } + const formatter = formatters[format]; + + let filter = defaults.filter; + if (typeof opts.filter === 'function' || isArray(opts.filter)) { + filter = opts.filter; + } + + let arrayFormat: StringifyOptions['arrayFormat']; + if (opts.arrayFormat && opts.arrayFormat in array_prefix_generators) { + arrayFormat = opts.arrayFormat; + } else if ('indices' in opts) { + arrayFormat = opts.indices ? 'indices' : 'repeat'; + } else { + arrayFormat = defaults.arrayFormat; + } + + if ('commaRoundTrip' in opts && typeof opts.commaRoundTrip !== 'boolean') { + throw new TypeError('`commaRoundTrip` must be a boolean, or absent'); + } + + const allowDots = + typeof opts.allowDots === 'undefined' ? + !!opts.encodeDotInKeys === true ? + true + : defaults.allowDots + : !!opts.allowDots; + + return { + addQueryPrefix: typeof opts.addQueryPrefix === 'boolean' ? opts.addQueryPrefix : defaults.addQueryPrefix, + // @ts-ignore + allowDots: allowDots, + allowEmptyArrays: + typeof opts.allowEmptyArrays === 'boolean' ? !!opts.allowEmptyArrays : defaults.allowEmptyArrays, + arrayFormat: arrayFormat, + charset: charset, + charsetSentinel: + typeof opts.charsetSentinel === 'boolean' ? opts.charsetSentinel : defaults.charsetSentinel, + commaRoundTrip: !!opts.commaRoundTrip, + delimiter: typeof opts.delimiter === 'undefined' ? defaults.delimiter : opts.delimiter, + encode: typeof opts.encode === 'boolean' ? opts.encode : defaults.encode, + encodeDotInKeys: + typeof opts.encodeDotInKeys === 'boolean' ? opts.encodeDotInKeys : defaults.encodeDotInKeys, + encoder: typeof opts.encoder === 'function' ? opts.encoder : defaults.encoder, + encodeValuesOnly: + typeof opts.encodeValuesOnly === 'boolean' ? opts.encodeValuesOnly : defaults.encodeValuesOnly, + filter: filter, + format: format, + formatter: formatter, + serializeDate: typeof opts.serializeDate === 'function' ? opts.serializeDate : defaults.serializeDate, + skipNulls: typeof opts.skipNulls === 'boolean' ? opts.skipNulls : defaults.skipNulls, + // @ts-ignore + sort: typeof opts.sort === 'function' ? opts.sort : null, + strictNullHandling: + typeof opts.strictNullHandling === 'boolean' ? opts.strictNullHandling : defaults.strictNullHandling, + }; +} + +export function stringify(object: any, opts: StringifyOptions = {}) { + let obj = object; + const options = normalize_stringify_options(opts); + + let obj_keys: PropertyKey[] | undefined; + let filter; + + if (typeof options.filter === 'function') { + filter = options.filter; + obj = filter('', obj); + } else if (isArray(options.filter)) { + filter = options.filter; + obj_keys = filter; + } + + const keys: string[] = []; + + if (typeof obj !== 'object' || obj === null) { + return ''; + } + + const generateArrayPrefix = array_prefix_generators[options.arrayFormat]; + const commaRoundTrip = generateArrayPrefix === 'comma' && options.commaRoundTrip; + + if (!obj_keys) { + obj_keys = Object.keys(obj); + } + + if (options.sort) { + obj_keys.sort(options.sort); + } + + const sideChannel = new WeakMap(); + for (let i = 0; i < obj_keys.length; ++i) { + const key = obj_keys[i]!; + + if (options.skipNulls && obj[key] === null) { + continue; + } + push_to_array( + keys, + inner_stringify( + obj[key], + key, + // @ts-expect-error + generateArrayPrefix, + commaRoundTrip, + options.allowEmptyArrays, + options.strictNullHandling, + options.skipNulls, + options.encodeDotInKeys, + options.encode ? options.encoder : null, + options.filter, + options.sort, + options.allowDots, + options.serializeDate, + options.format, + options.formatter, + options.encodeValuesOnly, + options.charset, + sideChannel, + ), + ); + } + + const joined = keys.join(options.delimiter); + let prefix = options.addQueryPrefix === true ? '?' : ''; + + if (options.charsetSentinel) { + if (options.charset === 'iso-8859-1') { + // encodeURIComponent('✓'), the "numeric entity" representation of a checkmark + prefix += 'utf8=%26%2310003%3B&'; + } else { + // encodeURIComponent('✓') + prefix += 'utf8=%E2%9C%93&'; + } + } + + return joined.length > 0 ? prefix + joined : ''; +} diff --git a/src/internal/qs/types.ts b/src/internal/qs/types.ts new file mode 100644 index 0000000..7c28dbb --- /dev/null +++ b/src/internal/qs/types.ts @@ -0,0 +1,71 @@ +export type Format = 'RFC1738' | 'RFC3986'; + +export type DefaultEncoder = (str: any, defaultEncoder?: any, charset?: string) => string; +export type DefaultDecoder = (str: string, decoder?: any, charset?: string) => string; + +export type BooleanOptional = boolean | undefined; + +export type StringifyBaseOptions = { + delimiter?: string; + allowDots?: boolean; + encodeDotInKeys?: boolean; + strictNullHandling?: boolean; + skipNulls?: boolean; + encode?: boolean; + encoder?: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: 'key' | 'value', + format?: Format, + ) => string; + filter?: Array | ((prefix: PropertyKey, value: any) => any); + arrayFormat?: 'indices' | 'brackets' | 'repeat' | 'comma'; + indices?: boolean; + sort?: ((a: PropertyKey, b: PropertyKey) => number) | null; + serializeDate?: (d: Date) => string; + format?: 'RFC1738' | 'RFC3986'; + formatter?: (str: PropertyKey) => string; + encodeValuesOnly?: boolean; + addQueryPrefix?: boolean; + charset?: 'utf-8' | 'iso-8859-1'; + charsetSentinel?: boolean; + allowEmptyArrays?: boolean; + commaRoundTrip?: boolean; +}; + +export type StringifyOptions = StringifyBaseOptions; + +export type ParseBaseOptions = { + comma?: boolean; + delimiter?: string | RegExp; + depth?: number | false; + decoder?: (str: string, defaultDecoder: DefaultDecoder, charset: string, type: 'key' | 'value') => any; + arrayLimit?: number; + parseArrays?: boolean; + plainObjects?: boolean; + allowPrototypes?: boolean; + allowSparse?: boolean; + parameterLimit?: number; + strictDepth?: boolean; + strictNullHandling?: boolean; + ignoreQueryPrefix?: boolean; + charset?: 'utf-8' | 'iso-8859-1'; + charsetSentinel?: boolean; + interpretNumericEntities?: boolean; + allowEmptyArrays?: boolean; + duplicates?: 'combine' | 'first' | 'last'; + allowDots?: boolean; + decodeDotInKeys?: boolean; +}; + +export type ParseOptions = ParseBaseOptions; + +export type ParsedQs = { + [key: string]: undefined | string | string[] | ParsedQs | ParsedQs[]; +}; + +// Type to remove null or undefined union from each property +export type NonNullableProperties = { + [K in keyof T]-?: Exclude; +}; diff --git a/src/internal/qs/utils.ts b/src/internal/qs/utils.ts new file mode 100644 index 0000000..4cd5657 --- /dev/null +++ b/src/internal/qs/utils.ts @@ -0,0 +1,265 @@ +import { RFC1738 } from './formats'; +import type { DefaultEncoder, Format } from './types'; +import { isArray } from '../utils/values'; + +export let has = (obj: object, key: PropertyKey): boolean => ( + (has = (Object as any).hasOwn ?? Function.prototype.call.bind(Object.prototype.hasOwnProperty)), + has(obj, key) +); + +const hex_table = /* @__PURE__ */ (() => { + const array = []; + for (let i = 0; i < 256; ++i) { + array.push('%' + ((i < 16 ? '0' : '') + i.toString(16)).toUpperCase()); + } + + return array; +})(); + +function compact_queue>(queue: Array<{ obj: T; prop: string }>) { + while (queue.length > 1) { + const item = queue.pop(); + if (!item) continue; + + const obj = item.obj[item.prop]; + + if (isArray(obj)) { + const compacted: unknown[] = []; + + for (let j = 0; j < obj.length; ++j) { + if (typeof obj[j] !== 'undefined') { + compacted.push(obj[j]); + } + } + + // @ts-ignore + item.obj[item.prop] = compacted; + } + } +} + +function array_to_object(source: any[], options: { plainObjects: boolean }) { + const obj = options && options.plainObjects ? Object.create(null) : {}; + for (let i = 0; i < source.length; ++i) { + if (typeof source[i] !== 'undefined') { + obj[i] = source[i]; + } + } + + return obj; +} + +export function merge( + target: any, + source: any, + options: { plainObjects?: boolean; allowPrototypes?: boolean } = {}, +) { + if (!source) { + return target; + } + + if (typeof source !== 'object') { + if (isArray(target)) { + target.push(source); + } else if (target && typeof target === 'object') { + if ((options && (options.plainObjects || options.allowPrototypes)) || !has(Object.prototype, source)) { + target[source] = true; + } + } else { + return [target, source]; + } + + return target; + } + + if (!target || typeof target !== 'object') { + return [target].concat(source); + } + + let mergeTarget = target; + if (isArray(target) && !isArray(source)) { + // @ts-ignore + mergeTarget = array_to_object(target, options); + } + + if (isArray(target) && isArray(source)) { + source.forEach(function (item, i) { + if (has(target, i)) { + const targetItem = target[i]; + if (targetItem && typeof targetItem === 'object' && item && typeof item === 'object') { + target[i] = merge(targetItem, item, options); + } else { + target.push(item); + } + } else { + target[i] = item; + } + }); + return target; + } + + return Object.keys(source).reduce(function (acc, key) { + const value = source[key]; + + if (has(acc, key)) { + acc[key] = merge(acc[key], value, options); + } else { + acc[key] = value; + } + return acc; + }, mergeTarget); +} + +export function assign_single_source(target: any, source: any) { + return Object.keys(source).reduce(function (acc, key) { + acc[key] = source[key]; + return acc; + }, target); +} + +export function decode(str: string, _: any, charset: string) { + const strWithoutPlus = str.replace(/\+/g, ' '); + if (charset === 'iso-8859-1') { + // unescape never throws, no try...catch needed: + return strWithoutPlus.replace(/%[0-9a-f]{2}/gi, unescape); + } + // utf-8 + try { + return decodeURIComponent(strWithoutPlus); + } catch (e) { + return strWithoutPlus; + } +} + +const limit = 1024; + +export const encode: ( + str: any, + defaultEncoder: DefaultEncoder, + charset: string, + type: 'key' | 'value', + format: Format, +) => string = (str, _defaultEncoder, charset, _kind, format: Format) => { + // This code was originally written by Brian White for the io.js core querystring library. + // It has been adapted here for stricter adherence to RFC 3986 + if (str.length === 0) { + return str; + } + + let string = str; + if (typeof str === 'symbol') { + string = Symbol.prototype.toString.call(str); + } else if (typeof str !== 'string') { + string = String(str); + } + + if (charset === 'iso-8859-1') { + return escape(string).replace(/%u[0-9a-f]{4}/gi, function ($0) { + return '%26%23' + parseInt($0.slice(2), 16) + '%3B'; + }); + } + + let out = ''; + for (let j = 0; j < string.length; j += limit) { + const segment = string.length >= limit ? string.slice(j, j + limit) : string; + const arr = []; + + for (let i = 0; i < segment.length; ++i) { + let c = segment.charCodeAt(i); + if ( + c === 0x2d || // - + c === 0x2e || // . + c === 0x5f || // _ + c === 0x7e || // ~ + (c >= 0x30 && c <= 0x39) || // 0-9 + (c >= 0x41 && c <= 0x5a) || // a-z + (c >= 0x61 && c <= 0x7a) || // A-Z + (format === RFC1738 && (c === 0x28 || c === 0x29)) // ( ) + ) { + arr[arr.length] = segment.charAt(i); + continue; + } + + if (c < 0x80) { + arr[arr.length] = hex_table[c]; + continue; + } + + if (c < 0x800) { + arr[arr.length] = hex_table[0xc0 | (c >> 6)]! + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + if (c < 0xd800 || c >= 0xe000) { + arr[arr.length] = + hex_table[0xe0 | (c >> 12)]! + hex_table[0x80 | ((c >> 6) & 0x3f)] + hex_table[0x80 | (c & 0x3f)]; + continue; + } + + i += 1; + c = 0x10000 + (((c & 0x3ff) << 10) | (segment.charCodeAt(i) & 0x3ff)); + + arr[arr.length] = + hex_table[0xf0 | (c >> 18)]! + + hex_table[0x80 | ((c >> 12) & 0x3f)] + + hex_table[0x80 | ((c >> 6) & 0x3f)] + + hex_table[0x80 | (c & 0x3f)]; + } + + out += arr.join(''); + } + + return out; +}; + +export function compact(value: any) { + const queue = [{ obj: { o: value }, prop: 'o' }]; + const refs = []; + + for (let i = 0; i < queue.length; ++i) { + const item = queue[i]; + // @ts-ignore + const obj = item.obj[item.prop]; + + const keys = Object.keys(obj); + for (let j = 0; j < keys.length; ++j) { + const key = keys[j]!; + const val = obj[key]; + if (typeof val === 'object' && val !== null && refs.indexOf(val) === -1) { + queue.push({ obj: obj, prop: key }); + refs.push(val); + } + } + } + + compact_queue(queue); + + return value; +} + +export function is_regexp(obj: any) { + return Object.prototype.toString.call(obj) === '[object RegExp]'; +} + +export function is_buffer(obj: any) { + if (!obj || typeof obj !== 'object') { + return false; + } + + return !!(obj.constructor && obj.constructor.isBuffer && obj.constructor.isBuffer(obj)); +} + +export function combine(a: any, b: any) { + return [].concat(a, b); +} + +export function maybe_map(val: T[], fn: (v: T) => T) { + if (isArray(val)) { + const mapped = []; + for (let i = 0; i < val.length; i += 1) { + mapped.push(fn(val[i]!)); + } + return mapped; + } + return fn(val); +} diff --git a/src/internal/request-options.ts b/src/internal/request-options.ts new file mode 100644 index 0000000..2aabf9a --- /dev/null +++ b/src/internal/request-options.ts @@ -0,0 +1,91 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { NullableHeaders } from './headers'; + +import type { BodyInit } from './builtin-types'; +import type { HTTPMethod, MergedRequestInit } from './types'; +import { type HeadersLike } from './headers'; + +export type FinalRequestOptions = RequestOptions & { method: HTTPMethod; path: string }; + +export type RequestOptions = { + /** + * The HTTP method for the request (e.g., 'get', 'post', 'put', 'delete'). + */ + method?: HTTPMethod; + + /** + * The URL path for the request. + * + * @example "/v1/foo" + */ + path?: string; + + /** + * Query parameters to include in the request URL. + */ + query?: object | undefined | null; + + /** + * The request body. Can be a string, JSON object, FormData, or other supported types. + */ + body?: unknown; + + /** + * HTTP headers to include with the request. Can be a Headers object, plain object, or array of tuples. + */ + headers?: HeadersLike; + + /** + * The maximum number of times that the client will retry a request in case of a + * temporary failure, like a network error or a 5XX error from the server. + * + * @default 2 + */ + maxRetries?: number; + + stream?: boolean | undefined; + + /** + * The maximum amount of time (in milliseconds) that the client should wait for a response + * from the server before timing out a single request. + * + * @unit milliseconds + */ + timeout?: number; + + /** + * Additional `RequestInit` options to be passed to the underlying `fetch` call. + * These options will be merged with the client's default fetch options. + */ + fetchOptions?: MergedRequestInit; + + /** + * An AbortSignal that can be used to cancel the request. + */ + signal?: AbortSignal | undefined | null; + + /** + * A unique key for this request to enable idempotency. + */ + idempotencyKey?: string; + + /** + * Override the default base URL for this specific request. + */ + defaultBaseURL?: string | undefined; + + __binaryResponse?: boolean | undefined; +}; + +export type EncodedContent = { bodyHeaders: HeadersLike; body: BodyInit }; +export type RequestEncoder = (request: { headers: NullableHeaders; body: unknown }) => EncodedContent; + +export const FallbackEncoder: RequestEncoder = ({ headers, body }) => { + return { + bodyHeaders: { + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + }; +}; diff --git a/src/internal/shim-types.ts b/src/internal/shim-types.ts new file mode 100644 index 0000000..8ddf7b0 --- /dev/null +++ b/src/internal/shim-types.ts @@ -0,0 +1,26 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Shims for types that we can't always rely on being available globally. + * + * Note: these only exist at the type-level, there is no corresponding runtime + * version for any of these symbols. + */ + +type NeverToAny = T extends never ? any : T; + +/** @ts-ignore */ +type _DOMReadableStream = globalThis.ReadableStream; + +/** @ts-ignore */ +type _NodeReadableStream = import('stream/web').ReadableStream; + +type _ConditionalNodeReadableStream = + typeof globalThis extends { ReadableStream: any } ? never : _NodeReadableStream; + +type _ReadableStream = NeverToAny< + | ([0] extends [1 & _DOMReadableStream] ? never : _DOMReadableStream) + | ([0] extends [1 & _ConditionalNodeReadableStream] ? never : _ConditionalNodeReadableStream) +>; + +export type { _ReadableStream as ReadableStream }; diff --git a/src/internal/shims.ts b/src/internal/shims.ts new file mode 100644 index 0000000..6d4fc22 --- /dev/null +++ b/src/internal/shims.ts @@ -0,0 +1,107 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * This module provides internal shims and utility functions for environments where certain Node.js or global types may not be available. + * + * These are used to ensure we can provide a consistent behaviour between different JavaScript environments and good error + * messages in cases where an environment isn't fully supported. + */ + +import type { Fetch } from './builtin-types'; +import type { ReadableStream } from './shim-types'; + +export function getDefaultFetch(): Fetch { + if (typeof fetch !== 'undefined') { + return fetch as any; + } + + throw new Error( + '`fetch` is not defined as a global; Either pass `fetch` to the client, `new Langsmith({ fetch })` or polyfill the global, `globalThis.fetch = fetch`', + ); +} + +type ReadableStreamArgs = ConstructorParameters; + +export function makeReadableStream(...args: ReadableStreamArgs): ReadableStream { + const ReadableStream = (globalThis as any).ReadableStream; + if (typeof ReadableStream === 'undefined') { + // Note: All of the platforms / runtimes we officially support already define + // `ReadableStream` as a global, so this should only ever be hit on unsupported runtimes. + throw new Error( + '`ReadableStream` is not defined as a global; You will need to polyfill it, `globalThis.ReadableStream = ReadableStream`', + ); + } + + return new ReadableStream(...args); +} + +export function ReadableStreamFrom(iterable: Iterable | AsyncIterable): ReadableStream { + let iter: AsyncIterator | Iterator = + Symbol.asyncIterator in iterable ? iterable[Symbol.asyncIterator]() : iterable[Symbol.iterator](); + + return makeReadableStream({ + start() {}, + async pull(controller: any) { + const { done, value } = await iter.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + async cancel() { + await iter.return?.(); + }, + }); +} + +/** + * Most browsers don't yet have async iterable support for ReadableStream, + * and Node has a very different way of reading bytes from its "ReadableStream". + * + * This polyfill was pulled from https://github.com/MattiasBuelens/web-streams-polyfill/pull/122#issuecomment-1627354490 + */ +export function ReadableStreamToAsyncIterable(stream: any): AsyncIterableIterator { + if (stream[Symbol.asyncIterator]) return stream; + + const reader = stream.getReader(); + return { + async next() { + try { + const result = await reader.read(); + if (result?.done) reader.releaseLock(); // release lock when stream becomes closed + return result; + } catch (e) { + reader.releaseLock(); // release lock when stream becomes errored + throw e; + } + }, + async return() { + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** + * Cancels a ReadableStream we don't need to consume. + * See https://undici.nodejs.org/#/?id=garbage-collection + */ +export async function CancelReadableStream(stream: any): Promise { + if (stream === null || typeof stream !== 'object') return; + + if (stream[Symbol.asyncIterator]) { + await stream[Symbol.asyncIterator]().return?.(); + return; + } + + const reader = stream.getReader(); + const cancelPromise = reader.cancel(); + reader.releaseLock(); + await cancelPromise; +} diff --git a/src/internal/to-file.ts b/src/internal/to-file.ts new file mode 100644 index 0000000..30eada3 --- /dev/null +++ b/src/internal/to-file.ts @@ -0,0 +1,154 @@ +import { BlobPart, getName, makeFile, isAsyncIterable } from './uploads'; +import type { FilePropertyBag } from './builtin-types'; +import { checkFileSupport } from './uploads'; + +type BlobLikePart = string | ArrayBuffer | ArrayBufferView | BlobLike | DataView; + +/** + * Intended to match DOM Blob, node-fetch Blob, node:buffer Blob, etc. + * Don't add arrayBuffer here, node-fetch doesn't have it + */ +interface BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/size) */ + readonly size: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) */ + readonly type: string; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/text) */ + text(): Promise; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/slice) */ + slice(start?: number, end?: number): BlobLike; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isBlobLike = (value: any): value is BlobLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.size === 'number' && + typeof value.type === 'string' && + typeof value.text === 'function' && + typeof value.slice === 'function' && + typeof value.arrayBuffer === 'function'; + +/** + * Intended to match DOM File, node:buffer File, undici File, etc. + */ +interface FileLike extends BlobLike { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/lastModified) */ + readonly lastModified: number; + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/File/name) */ + readonly name?: string | undefined; +} + +/** + * This check adds the arrayBuffer() method type because it is available and used at runtime + */ +const isFileLike = (value: any): value is FileLike & { arrayBuffer(): Promise } => + value != null && + typeof value === 'object' && + typeof value.name === 'string' && + typeof value.lastModified === 'number' && + isBlobLike(value); + +/** + * Intended to match DOM Response, node-fetch Response, undici Response, etc. + */ +export interface ResponseLike { + url: string; + blob(): Promise; +} + +const isResponseLike = (value: any): value is ResponseLike => + value != null && + typeof value === 'object' && + typeof value.url === 'string' && + typeof value.blob === 'function'; + +export type ToFileInput = + | FileLike + | ResponseLike + | Exclude + | AsyncIterable; + +/** + * Helper for creating a {@link File} to pass to an SDK upload method from a variety of different data formats + * @param value the raw content of the file. Can be an {@link Uploadable}, BlobLikePart, or AsyncIterable of BlobLikeParts + * @param {string=} name the name of the file. If omitted, toFile will try to determine a file name from bits if possible + * @param {Object=} options additional properties + * @param {string=} options.type the MIME type of the content + * @param {number=} options.lastModified the last modified timestamp + * @returns a {@link File} with the given properties + */ +export async function toFile( + value: ToFileInput | PromiseLike, + name?: string | null | undefined, + options?: FilePropertyBag | undefined, +): Promise { + checkFileSupport(); + + // If it's a promise, resolve it. + value = await value; + + // If we've been given a `File` we don't need to do anything + if (isFileLike(value)) { + if (value instanceof File) { + return value; + } + return makeFile([await value.arrayBuffer()], value.name); + } + + if (isResponseLike(value)) { + const blob = await value.blob(); + name ||= new URL(value.url).pathname.split(/[\\/]/).pop(); + + return makeFile(await getBytes(blob), name, options); + } + + const parts = await getBytes(value); + + name ||= getName(value); + + if (!options?.type) { + const type = parts.find((part) => typeof part === 'object' && 'type' in part && part.type); + if (typeof type === 'string') { + options = { ...options, type }; + } + } + + return makeFile(parts, name, options); +} + +async function getBytes(value: BlobLikePart | AsyncIterable): Promise> { + let parts: Array = []; + if ( + typeof value === 'string' || + ArrayBuffer.isView(value) || // includes Uint8Array, Buffer, etc. + value instanceof ArrayBuffer + ) { + parts.push(value); + } else if (isBlobLike(value)) { + parts.push(value instanceof Blob ? value : await value.arrayBuffer()); + } else if ( + isAsyncIterable(value) // includes Readable, ReadableStream, etc. + ) { + for await (const chunk of value) { + parts.push(...(await getBytes(chunk as BlobLikePart))); // TODO, consider validating? + } + } else { + const constructor = value?.constructor?.name; + throw new Error( + `Unexpected data type: ${typeof value}${ + constructor ? `; constructor: ${constructor}` : '' + }${propsForError(value)}`, + ); + } + + return parts; +} + +function propsForError(value: unknown): string { + if (typeof value !== 'object' || value === null) return ''; + const props = Object.getOwnPropertyNames(value); + return `; props: [${props.map((p) => `"${p}"`).join(', ')}]`; +} diff --git a/src/internal/types.ts b/src/internal/types.ts new file mode 100644 index 0000000..a050513 --- /dev/null +++ b/src/internal/types.ts @@ -0,0 +1,93 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export type PromiseOrValue = T | Promise; +export type HTTPMethod = 'get' | 'post' | 'put' | 'patch' | 'delete'; + +export type KeysEnum = { [P in keyof Required]: true }; + +export type FinalizedRequestInit = RequestInit & { headers: Headers }; + +type NotAny = [0] extends [1 & T] ? never : T; + +/** + * Some environments overload the global fetch function, and Parameters only gets the last signature. + */ +type OverloadedParameters = + T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + (...args: infer D): unknown; + } + ) ? + A | B | C | D + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + (...args: infer C): unknown; + } + ) ? + A | B | C + : T extends ( + { + (...args: infer A): unknown; + (...args: infer B): unknown; + } + ) ? + A | B + : T extends (...args: infer A) => unknown ? A + : never; + +/** + * These imports attempt to get types from a parent package's dependencies. + * Unresolved bare specifiers can trigger [automatic type acquisition][1] in some projects, which + * would cause typescript to show types not present at runtime. To avoid this, we import + * directly from parent node_modules folders. + * + * We need to check multiple levels because we don't know what directory structure we'll be in. + * For example, pnpm generates directories like this: + * ``` + * node_modules + * ├── .pnpm + * │ └── pkg@1.0.0 + * │ └── node_modules + * │ └── pkg + * │ └── internal + * │ └── types.d.ts + * ├── pkg -> .pnpm/pkg@1.0.0/node_modules/pkg + * └── undici + * ``` + * + * [1]: https://www.typescriptlang.org/tsconfig/#typeAcquisition + */ +/** @ts-ignore For users with \@types/node */ /* prettier-ignore */ +type UndiciTypesRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with undici */ /* prettier-ignore */ +type UndiciRequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with \@types/bun */ /* prettier-ignore */ +type BunRequestInit = globalThis.FetchRequestInit; +/** @ts-ignore For users with node-fetch@2 */ /* prettier-ignore */ +type NodeFetch2RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users with node-fetch@3, doesn't need file extension because types are at ./@types/index.d.ts */ /* prettier-ignore */ +type NodeFetch3RequestInit = NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny | NotAny; +/** @ts-ignore For users who use Deno */ /* prettier-ignore */ +type FetchRequestInit = NonNullable[1]>; + +type RequestInits = + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny + | NotAny; + +/** + * This type contains `RequestInit` options that may be available on the current runtime, + * including per-platform extensions like `dispatcher`, `agent`, `client`, etc. + */ +export type MergedRequestInit = RequestInits & + /** We don't include these in the types as they'll be overridden for every request. */ + Partial>; diff --git a/src/internal/uploads.ts b/src/internal/uploads.ts new file mode 100644 index 0000000..3ed340e --- /dev/null +++ b/src/internal/uploads.ts @@ -0,0 +1,187 @@ +import { type RequestOptions } from './request-options'; +import type { FilePropertyBag, Fetch } from './builtin-types'; +import type { Langsmith } from '../client'; +import { ReadableStreamFrom } from './shims'; + +export type BlobPart = string | ArrayBuffer | ArrayBufferView | Blob | DataView; +type FsReadStream = AsyncIterable & { path: string | { toString(): string } }; + +// https://github.com/oven-sh/bun/issues/5980 +interface BunFile extends Blob { + readonly name?: string | undefined; +} + +export const checkFileSupport = () => { + if (typeof File === 'undefined') { + const { process } = globalThis as any; + const isOldNode = + typeof process?.versions?.node === 'string' && parseInt(process.versions.node.split('.')) < 20; + throw new Error( + '`File` is not defined as a global, which is required for file uploads.' + + (isOldNode ? + " Update to Node 20 LTS or newer, or set `globalThis.File` to `import('node:buffer').File`." + : ''), + ); + } +}; + +/** + * Typically, this is a native "File" class. + * + * We provide the {@link toFile} utility to convert a variety of objects + * into the File class. + * + * For convenience, you can also pass a fetch Response, or in Node, + * the result of fs.createReadStream(). + */ +export type Uploadable = File | Response | FsReadStream | BunFile; + +/** + * Construct a `File` instance. This is used to ensure a helpful error is thrown + * for environments that don't define a global `File` yet. + */ +export function makeFile( + fileBits: BlobPart[], + fileName: string | undefined, + options?: FilePropertyBag, +): File { + checkFileSupport(); + return new File(fileBits as any, fileName ?? 'unknown_file', options); +} + +export function getName(value: any): string | undefined { + return ( + ( + (typeof value === 'object' && + value !== null && + (('name' in value && value.name && String(value.name)) || + ('url' in value && value.url && String(value.url)) || + ('filename' in value && value.filename && String(value.filename)) || + ('path' in value && value.path && String(value.path)))) || + '' + ) + .split(/[\\/]/) + .pop() || undefined + ); +} + +export const isAsyncIterable = (value: any): value is AsyncIterable => + value != null && typeof value === 'object' && typeof value[Symbol.asyncIterator] === 'function'; + +/** + * Returns a multipart/form-data request if any part of the given request body contains a File / Blob value. + * Otherwise returns the request as is. + */ +export const maybeMultipartFormRequestOptions = async ( + opts: RequestOptions, + fetch: Langsmith | Fetch, +): Promise => { + if (!hasUploadableValue(opts.body)) return opts; + + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +type MultipartFormRequestOptions = Omit & { body: unknown }; + +export const multipartFormRequestOptions = async ( + opts: MultipartFormRequestOptions, + fetch: Langsmith | Fetch, +): Promise => { + return { ...opts, body: await createForm(opts.body, fetch) }; +}; + +const supportsFormDataMap = /* @__PURE__ */ new WeakMap>(); + +/** + * node-fetch doesn't support the global FormData object in recent node versions. Instead of sending + * properly-encoded form data, it just stringifies the object, resulting in a request body of "[object FormData]". + * This function detects if the fetch function provided supports the global FormData object to avoid + * confusing error messages later on. + */ +function supportsFormData(fetchObject: Langsmith | Fetch): Promise { + const fetch: Fetch = typeof fetchObject === 'function' ? fetchObject : (fetchObject as any).fetch; + const cached = supportsFormDataMap.get(fetch); + if (cached) return cached; + const promise = (async () => { + try { + const FetchResponse = ( + 'Response' in fetch ? + fetch.Response + : (await fetch('data:,')).constructor) as typeof Response; + const data = new FormData(); + if (data.toString() === (await new FetchResponse(data).text())) { + return false; + } + return true; + } catch { + // avoid false negatives + return true; + } + })(); + supportsFormDataMap.set(fetch, promise); + return promise; +} + +export const createForm = async >( + body: T | undefined, + fetch: Langsmith | Fetch, +): Promise => { + if (!(await supportsFormData(fetch))) { + throw new TypeError( + 'The provided fetch function does not support file uploads with the current global FormData class.', + ); + } + const form = new FormData(); + await Promise.all(Object.entries(body || {}).map(([key, value]) => addFormValue(form, key, value))); + return form; +}; + +// We check for Blob not File because Bun.File doesn't inherit from File, +// but they both inherit from Blob and have a `name` property at runtime. +const isNamedBlob = (value: unknown) => value instanceof Blob && 'name' in value; + +const isUploadable = (value: unknown) => + typeof value === 'object' && + value !== null && + (value instanceof Response || isAsyncIterable(value) || isNamedBlob(value)); + +const hasUploadableValue = (value: unknown): boolean => { + if (isUploadable(value)) return true; + if (Array.isArray(value)) return value.some(hasUploadableValue); + if (value && typeof value === 'object') { + for (const k in value) { + if (hasUploadableValue((value as any)[k])) return true; + } + } + return false; +}; + +const addFormValue = async (form: FormData, key: string, value: unknown): Promise => { + if (value === undefined) return; + if (value == null) { + throw new TypeError( + `Received null for "${key}"; to pass null in FormData, you must use the string 'null'`, + ); + } + + // TODO: make nested formats configurable + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + form.append(key, String(value)); + } else if (value instanceof Response) { + form.append(key, makeFile([await value.blob()], getName(value))); + } else if (isAsyncIterable(value)) { + form.append(key, makeFile([await new Response(ReadableStreamFrom(value)).blob()], getName(value))); + } else if (isNamedBlob(value)) { + form.append(key, value, getName(value)); + } else if (Array.isArray(value)) { + await Promise.all(value.map((entry) => addFormValue(form, key + '[]', entry))); + } else if (typeof value === 'object') { + await Promise.all( + Object.entries(value).map(([name, prop]) => addFormValue(form, `${key}[${name}]`, prop)), + ); + } else { + throw new TypeError( + `Invalid value given to form, expected a string, number, boolean, object, Array, File or Blob but got ${value} instead`, + ); + } +}; diff --git a/src/internal/utils.ts b/src/internal/utils.ts new file mode 100644 index 0000000..c591353 --- /dev/null +++ b/src/internal/utils.ts @@ -0,0 +1,9 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './utils/values'; +export * from './utils/base64'; +export * from './utils/env'; +export * from './utils/log'; +export * from './utils/uuid'; +export * from './utils/sleep'; +export * from './utils/query'; diff --git a/src/internal/utils/base64.ts b/src/internal/utils/base64.ts new file mode 100644 index 0000000..fc25b0f --- /dev/null +++ b/src/internal/utils/base64.ts @@ -0,0 +1,40 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { LangsmithError } from '../../core/error'; +import { encodeUTF8 } from './bytes'; + +export const toBase64 = (data: string | Uint8Array | null | undefined): string => { + if (!data) return ''; + + if (typeof (globalThis as any).Buffer !== 'undefined') { + return (globalThis as any).Buffer.from(data).toString('base64'); + } + + if (typeof data === 'string') { + data = encodeUTF8(data); + } + + if (typeof btoa !== 'undefined') { + return btoa(String.fromCharCode.apply(null, data as any)); + } + + throw new LangsmithError('Cannot generate base64 string; Expected `Buffer` or `btoa` to be defined'); +}; + +export const fromBase64 = (str: string): Uint8Array => { + if (typeof (globalThis as any).Buffer !== 'undefined') { + const buf = (globalThis as any).Buffer.from(str, 'base64'); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + + if (typeof atob !== 'undefined') { + const bstr = atob(str); + const buf = new Uint8Array(bstr.length); + for (let i = 0; i < bstr.length; i++) { + buf[i] = bstr.charCodeAt(i); + } + return buf; + } + + throw new LangsmithError('Cannot decode base64 string; Expected `Buffer` or `atob` to be defined'); +}; diff --git a/src/internal/utils/bytes.ts b/src/internal/utils/bytes.ts new file mode 100644 index 0000000..8da627a --- /dev/null +++ b/src/internal/utils/bytes.ts @@ -0,0 +1,32 @@ +export function concatBytes(buffers: Uint8Array[]): Uint8Array { + let length = 0; + for (const buffer of buffers) { + length += buffer.length; + } + const output = new Uint8Array(length); + let index = 0; + for (const buffer of buffers) { + output.set(buffer, index); + index += buffer.length; + } + + return output; +} + +let encodeUTF8_: (str: string) => Uint8Array; +export function encodeUTF8(str: string) { + let encoder; + return ( + encodeUTF8_ ?? + ((encoder = new (globalThis as any).TextEncoder()), (encodeUTF8_ = encoder.encode.bind(encoder))) + )(str); +} + +let decodeUTF8_: (bytes: Uint8Array) => string; +export function decodeUTF8(bytes: Uint8Array) { + let decoder; + return ( + decodeUTF8_ ?? + ((decoder = new (globalThis as any).TextDecoder()), (decodeUTF8_ = decoder.decode.bind(decoder))) + )(bytes); +} diff --git a/src/internal/utils/env.ts b/src/internal/utils/env.ts new file mode 100644 index 0000000..cc5fa0f --- /dev/null +++ b/src/internal/utils/env.ts @@ -0,0 +1,18 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * Read an environment variable. + * + * Trims beginning and trailing whitespace. + * + * Will return undefined if the environment variable doesn't exist or cannot be accessed. + */ +export const readEnv = (env: string): string | undefined => { + if (typeof (globalThis as any).process !== 'undefined') { + return (globalThis as any).process.env?.[env]?.trim() || undefined; + } + if (typeof (globalThis as any).Deno !== 'undefined') { + return (globalThis as any).Deno.env?.get?.(env)?.trim() || undefined; + } + return undefined; +}; diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts new file mode 100644 index 0000000..a4efa84 --- /dev/null +++ b/src/internal/utils/log.ts @@ -0,0 +1,129 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { hasOwn } from './values'; +import { type Langsmith } from '../../client'; +import { RequestOptions } from '../request-options'; + +type LogFn = (message: string, ...rest: unknown[]) => void; +export type Logger = { + error: LogFn; + warn: LogFn; + info: LogFn; + debug: LogFn; +}; +export type LogLevel = 'off' | 'error' | 'warn' | 'info' | 'debug'; + +const levelNumbers = { + off: 0, + error: 200, + warn: 300, + info: 400, + debug: 500, +}; + +export const parseLogLevel = ( + maybeLevel: string | undefined, + sourceName: string, + client: Langsmith, +): LogLevel | undefined => { + if (!maybeLevel) { + return undefined; + } + if (hasOwn(levelNumbers, maybeLevel)) { + return maybeLevel; + } + loggerFor(client).warn( + `${sourceName} was set to ${JSON.stringify(maybeLevel)}, expected one of ${JSON.stringify( + Object.keys(levelNumbers), + )}`, + ); + return undefined; +}; + +function noop() {} + +function makeLogFn(fnLevel: keyof Logger, logger: Logger | undefined, logLevel: LogLevel) { + if (!logger || levelNumbers[fnLevel] > levelNumbers[logLevel]) { + return noop; + } else { + // Don't wrap logger functions, we want the stacktrace intact! + return logger[fnLevel].bind(logger); + } +} + +const noopLogger = { + error: noop, + warn: noop, + info: noop, + debug: noop, +}; + +let cachedLoggers = /* @__PURE__ */ new WeakMap(); + +export function loggerFor(client: Langsmith): Logger { + const logger = client.logger; + const logLevel = client.logLevel ?? 'off'; + if (!logger) { + return noopLogger; + } + + const cachedLogger = cachedLoggers.get(logger); + if (cachedLogger && cachedLogger[0] === logLevel) { + return cachedLogger[1]; + } + + const levelLogger = { + error: makeLogFn('error', logger, logLevel), + warn: makeLogFn('warn', logger, logLevel), + info: makeLogFn('info', logger, logLevel), + debug: makeLogFn('debug', logger, logLevel), + }; + + cachedLoggers.set(logger, [logLevel, levelLogger]); + + return levelLogger; +} + +export const formatRequestDetails = (details: { + options?: RequestOptions | undefined; + headers?: Headers | Record | undefined; + retryOfRequestLogID?: string | undefined; + retryOf?: string | undefined; + url?: string | undefined; + status?: number | undefined; + method?: string | undefined; + durationMs?: number | undefined; + message?: unknown; + body?: unknown; +}) => { + if (details.options) { + details.options = { ...details.options }; + delete details.options['headers']; // redundant + leaks internals + } + if (details.headers) { + details.headers = Object.fromEntries( + (details.headers instanceof Headers ? [...details.headers] : Object.entries(details.headers)).map( + ([name, value]) => [ + name, + ( + name.toLowerCase() === 'authorization' || + name.toLowerCase() === 'api-key' || + name.toLowerCase() === 'x-api-key' || + name.toLowerCase() === 'cookie' || + name.toLowerCase() === 'set-cookie' || + name.toLowerCase() === 'x-tenant-id' + ) ? + '***' + : value, + ], + ), + ); + } + if ('retryOfRequestLogID' in details) { + if (details.retryOfRequestLogID) { + details.retryOf = details.retryOfRequestLogID; + } + delete details.retryOfRequestLogID; + } + return details; +}; diff --git a/src/internal/utils/path.ts b/src/internal/utils/path.ts new file mode 100644 index 0000000..d7c0500 --- /dev/null +++ b/src/internal/utils/path.ts @@ -0,0 +1,88 @@ +import { LangsmithError } from '../../core/error'; + +/** + * Percent-encode everything that isn't safe to have in a path without encoding safe chars. + * + * Taken from https://datatracker.ietf.org/doc/html/rfc3986#section-3.3: + * > unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" + * > sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" + * > pchar = unreserved / pct-encoded / sub-delims / ":" / "@" + */ +export function encodeURIPath(str: string) { + return str.replace(/[^A-Za-z0-9\-._~!$&'()*+,;=:@]+/g, encodeURIComponent); +} + +const EMPTY = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.create(null)); + +export const createPathTagFunction = (pathEncoder = encodeURIPath) => + function path(statics: readonly string[], ...params: readonly unknown[]): string { + // If there are no params, no processing is needed. + if (statics.length === 1) return statics[0]!; + + let postPath = false; + const invalidSegments = []; + const path = statics.reduce((previousValue, currentValue, index) => { + if (/[?#]/.test(currentValue)) { + postPath = true; + } + const value = params[index]; + let encoded = (postPath ? encodeURIComponent : pathEncoder)('' + value); + if ( + index !== params.length && + (value == null || + (typeof value === 'object' && + // handle values from other realms + value.toString === + Object.getPrototypeOf(Object.getPrototypeOf((value as any).hasOwnProperty ?? EMPTY) ?? EMPTY) + ?.toString)) + ) { + encoded = value + ''; + invalidSegments.push({ + start: previousValue.length + currentValue.length, + length: encoded.length, + error: `Value of type ${Object.prototype.toString + .call(value) + .slice(8, -1)} is not a valid path parameter`, + }); + } + return previousValue + currentValue + (index === params.length ? '' : encoded); + }, ''); + + const pathOnly = path.split(/[?#]/, 1)[0]!; + const invalidSegmentPattern = /(?<=^|\/)(?:\.|%2e){1,2}(?=\/|$)/gi; + let match; + + // Find all invalid segments + while ((match = invalidSegmentPattern.exec(pathOnly)) !== null) { + invalidSegments.push({ + start: match.index, + length: match[0].length, + error: `Value "${match[0]}" can\'t be safely passed as a path parameter`, + }); + } + + invalidSegments.sort((a, b) => a.start - b.start); + + if (invalidSegments.length > 0) { + let lastEnd = 0; + const underline = invalidSegments.reduce((acc, segment) => { + const spaces = ' '.repeat(segment.start - lastEnd); + const arrows = '^'.repeat(segment.length); + lastEnd = segment.start + segment.length; + return acc + spaces + arrows; + }, ''); + + throw new LangsmithError( + `Path parameters result in path with invalid segments:\n${invalidSegments + .map((e) => e.error) + .join('\n')}\n${path}\n${underline}`, + ); + } + + return path; + }; + +/** + * URI-encodes path params and ensures no unsafe /./ or /../ path segments are introduced. + */ +export const path = /* @__PURE__ */ createPathTagFunction(encodeURIPath); diff --git a/src/internal/utils/query.ts b/src/internal/utils/query.ts new file mode 100644 index 0000000..68554da --- /dev/null +++ b/src/internal/utils/query.ts @@ -0,0 +1,7 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import * as qs from '../qs/stringify'; + +export function stringifyQuery(query: object | Record) { + return qs.stringify(query, { arrayFormat: 'repeat' }); +} diff --git a/src/internal/utils/sleep.ts b/src/internal/utils/sleep.ts new file mode 100644 index 0000000..65e5296 --- /dev/null +++ b/src/internal/utils/sleep.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/src/internal/utils/uuid.ts b/src/internal/utils/uuid.ts new file mode 100644 index 0000000..b0e53aa --- /dev/null +++ b/src/internal/utils/uuid.ts @@ -0,0 +1,17 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +/** + * https://stackoverflow.com/a/2117523 + */ +export let uuid4 = function () { + const { crypto } = globalThis as any; + if (crypto?.randomUUID) { + uuid4 = crypto.randomUUID.bind(crypto); + return crypto.randomUUID(); + } + const u8 = new Uint8Array(1); + const randomByte = crypto ? () => crypto.getRandomValues(u8)[0]! : () => (Math.random() * 0xff) & 0xff; + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => + (+c ^ (randomByte() & (15 >> (+c / 4)))).toString(16), + ); +}; diff --git a/src/internal/utils/values.ts b/src/internal/utils/values.ts new file mode 100644 index 0000000..e3d9d0f --- /dev/null +++ b/src/internal/utils/values.ts @@ -0,0 +1,105 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { LangsmithError } from '../../core/error'; + +// https://url.spec.whatwg.org/#url-scheme-string +const startsWithSchemeRegexp = /^[a-z][a-z0-9+.-]*:/i; + +export const isAbsoluteURL = (url: string): boolean => { + return startsWithSchemeRegexp.test(url); +}; + +export let isArray = (val: unknown): val is unknown[] => ((isArray = Array.isArray), isArray(val)); +export let isReadonlyArray = isArray as (val: unknown) => val is readonly unknown[]; + +/** Returns an object if the given value isn't an object, otherwise returns as-is */ +export function maybeObj(x: unknown): object { + if (typeof x !== 'object') { + return {}; + } + + return x ?? {}; +} + +// https://stackoverflow.com/a/34491287 +export function isEmptyObj(obj: Object | null | undefined): boolean { + if (!obj) return true; + for (const _k in obj) return false; + return true; +} + +// https://eslint.org/docs/latest/rules/no-prototype-builtins +export function hasOwn(obj: T, key: PropertyKey): key is keyof T { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +export function isObj(obj: unknown): obj is Record { + return obj != null && typeof obj === 'object' && !Array.isArray(obj); +} + +export const ensurePresent = (value: T | null | undefined): T => { + if (value == null) { + throw new LangsmithError(`Expected a value to be given but received ${value} instead.`); + } + + return value; +}; + +export const validatePositiveInteger = (name: string, n: unknown): number => { + if (typeof n !== 'number' || !Number.isInteger(n)) { + throw new LangsmithError(`${name} must be an integer`); + } + if (n < 0) { + throw new LangsmithError(`${name} must be a positive integer`); + } + return n; +}; + +export const coerceInteger = (value: unknown): number => { + if (typeof value === 'number') return Math.round(value); + if (typeof value === 'string') return parseInt(value, 10); + + throw new LangsmithError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceFloat = (value: unknown): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') return parseFloat(value); + + throw new LangsmithError(`Could not coerce ${value} (type: ${typeof value}) into a number`); +}; + +export const coerceBoolean = (value: unknown): boolean => { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value === 'true'; + return Boolean(value); +}; + +export const maybeCoerceInteger = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceInteger(value); +}; + +export const maybeCoerceFloat = (value: unknown): number | undefined => { + if (value == null) { + return undefined; + } + return coerceFloat(value); +}; + +export const maybeCoerceBoolean = (value: unknown): boolean | undefined => { + if (value == null) { + return undefined; + } + return coerceBoolean(value); +}; + +export const safeJSON = (text: string) => { + try { + return JSON.parse(text); + } catch (err) { + return undefined; + } +}; diff --git a/src/jest.ts b/src/jest.ts new file mode 100644 index 0000000..726938f --- /dev/null +++ b/src/jest.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/jest/index'; diff --git a/src/jest/reporter.ts b/src/jest/reporter.ts new file mode 100644 index 0000000..0f4f38f --- /dev/null +++ b/src/jest/reporter.ts @@ -0,0 +1,3 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/jest/reporter'; +export { default } from '../lib/jest/reporter'; diff --git a/src/langchain.ts b/src/langchain.ts new file mode 100644 index 0000000..8add392 --- /dev/null +++ b/src/langchain.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/langchain'; diff --git a/src/lib/.keep b/src/lib/.keep new file mode 100644 index 0000000..7554f8b --- /dev/null +++ b/src/lib/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store custom files to expand the SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. diff --git a/src/lib/anonymizer/index.ts b/src/lib/anonymizer/index.ts new file mode 100644 index 0000000..c2ed547 --- /dev/null +++ b/src/lib/anonymizer/index.ts @@ -0,0 +1,326 @@ +export interface StringNode { + value: string; + path: string; +} + +interface StringNodeInternal extends StringNode { + // Direct reference to the parent object and key, so we can write back + // without path parsing/traversal (avoids prototype pollution entirely). + parent: Record; + key: string; + // Unique identity for matching after maskNodes processing. + _id: number; +} + +function extractStringNodes(data: unknown, options: { maxDepth?: number }) { + const parsedOptions = { ...options, maxDepth: options.maxDepth ?? 10 }; + + const queue: [ + value: unknown, + depth: number, + path: string, + parent: Record | null, + key: string, + ][] = [[data, 0, "", null, ""]]; + + let nextId = 0; + const result: StringNodeInternal[] = []; + // Head index, not `queue.shift()`: shift is O(n) per call, and this walk runs + // inline on the caller's tick, so a quadratic drain stalls unrelated work. + let head = 0; + while (head < queue.length) { + const [value, depth, path, parent, key] = queue[head++]; + if (typeof value === "string") { + result.push({ + value, + path, + parent: parent as Record, + key, + _id: nextId++, + }); + } else if (Array.isArray(value)) { + if (depth >= parsedOptions.maxDepth) continue; + for (let i = 0; i < value.length; i++) { + queue.push([ + value[i], + depth + 1, + `${path}[${i}]`, + value as unknown as Record, + String(i), + ]); + } + } else if (typeof value === "object" && value != null) { + if (depth >= parsedOptions.maxDepth) continue; + for (const [k, nestedValue] of Object.entries(value)) { + queue.push([ + nestedValue, + depth + 1, + path ? `${path}.${k}` : k, + value as Record, + k, + ]); + } + } + } + + return result; +} + +function deepClone(data: T): T { + return JSON.parse(JSON.stringify(data)); +} + +export interface StringNodeProcessor { + maskNodes: (nodes: StringNode[]) => StringNode[]; +} + +export interface StringNodeRule { + type?: "pattern"; + pattern: RegExp | string; + replace?: string; +} + +export type ReplacerType = + | ((value: string, path?: string) => string) + | StringNodeRule[] + | StringNodeProcessor; + +export function createAnonymizer( + replacer: ReplacerType, + options?: { maxDepth?: number }, +) { + return (data: T): T => { + let mutateValue = deepClone(data); + const nodes = extractStringNodes(mutateValue, { + maxDepth: options?.maxDepth, + }); + + const processor: StringNodeProcessor = Array.isArray(replacer) + ? (() => { + const replacers: [regex: RegExp, replace: string][] = replacer.map( + ({ pattern, type, replace }) => { + if (type != null && type !== "pattern") + throw new Error("Invalid anonymizer type"); + return [ + typeof pattern === "string" + ? new RegExp(pattern, "g") + : pattern, + replace ?? "[redacted]", + ]; + }, + ); + + if (replacers.length === 0) throw new Error("No replacers provided"); + return { + maskNodes: (nodes: StringNode[]) => { + return nodes.reduce((memo, item) => { + const newValue = replacers.reduce((value, [regex, replace]) => { + const result = value.replace(regex, replace); + + // make sure we reset the state of regex + regex.lastIndex = 0; + + return result; + }, item.value); + + if (newValue !== item.value) { + memo.push({ ...item, value: newValue }); + } + + return memo; + }, []); + }, + }; + })() + : typeof replacer === "function" + ? { + maskNodes: (nodes: StringNode[]) => + nodes.reduce((memo, item) => { + const newValue = replacer(item.value, item.path); + if (newValue !== item.value) { + memo.push({ ...item, value: newValue }); + } + + return memo; + }, []), + } + : replacer; + + // Build a lookup from _id to internal node for direct write-back. + const nodesById = new Map(); + for (const node of nodes) { + nodesById.set(node._id, node); + } + + const toUpdate = processor.maskNodes(nodes); + for (const node of toUpdate) { + if (node.path === "") { + mutateValue = node.value as unknown as T; + } else { + // Match by _id if available (built-in replacers propagate it from + // the input nodes), otherwise fall back to path matching. + const asInternal = node as Partial; + const internal = + asInternal._id !== undefined + ? nodesById.get(asInternal._id) + : nodes.find((n) => n.path === node.path); + if (internal) { + internal.parent[internal.key] = node.value; + } + } + } + + return mutateValue; + }; +} + +/** + * Replacement token written in place of detected secrets by + * {@link DEFAULT_SECRET_RULES} / {@link createSecretAnonymizer}. + */ +export const SECRET_PLACEHOLDER = "[SECRET_DETECTED]"; + +/** + * A curated, high-precision rule set for detecting common credentials in + * traced data (prompts, tool inputs/outputs, file contents, shell commands). + * + * Designed to favor *low false positives* over exhaustive coverage: + * - Provider rules are anchored to well-known key prefixes. + * - Structural rules only fire when a sensitive *name* (api_key, token, + * password, …) is paired with an assignment/separator, so ordinary code, + * UUIDs, and hashes are left intact. + * + * This is NOT a port of gitleaks/secretlint; pattern shapes are drawn from + * those projects (and provider docs) as a reference only. Every rule sets an + * explicit `replace` because {@link createAnonymizer}'s default is + * `[redacted]`, whereas the shared token here is {@link SECRET_PLACEHOLDER}. + * + * Patterns are written to port 1:1 to the Python SDK preset (no lookbehind). + */ +export const DEFAULT_SECRET_RULES: StringNodeRule[] = [ + // ── Provider API keys (prefix-anchored) ───────────────────────────────── + // Anthropic + { pattern: /sk-ant-[A-Za-z0-9_-]{20,}/g, replace: SECRET_PLACEHOLDER }, + // OpenAI: project / service-account / admin keys, then legacy `sk-...` + { + pattern: /sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}/g, + replace: SECRET_PLACEHOLDER, + }, + { pattern: /sk-[A-Za-z0-9]{32,}/g, replace: SECRET_PLACEHOLDER }, + // LangSmith (keys are multi-segment: lsv2_pt__ — match the + // full underscore-delimited tail so none of it leaks past the placeholder) + { + pattern: /lsv2_(?:pt|sk)_[A-Za-z0-9]{32,}(?:_[A-Za-z0-9]+)*/g, + replace: SECRET_PLACEHOLDER, + }, + { pattern: /ls__[A-Za-z0-9]{16,}/g, replace: SECRET_PLACEHOLDER }, + // GitHub personal access / app tokens + { pattern: /gh[pousr]_[A-Za-z0-9]{36,}/g, replace: SECRET_PLACEHOLDER }, + { pattern: /github_pat_[A-Za-z0-9_]{82}/g, replace: SECRET_PLACEHOLDER }, + // GitLab personal access token + { pattern: /glpat-[A-Za-z0-9_-]{20,}/g, replace: SECRET_PLACEHOLDER }, + // AWS access key id (covers AKIA/ASIA/ABIA/ACCA/A3T* prefixes) + { + pattern: /\b(?:AKIA|ASIA|ABIA|ACCA|A3T[A-Z0-9])[0-9A-Z]{16}\b/g, + replace: SECRET_PLACEHOLDER, + }, + // Google API key + OAuth access token + { pattern: /AIza[0-9A-Za-z_-]{35}/g, replace: SECRET_PLACEHOLDER }, + { pattern: /ya29\.[0-9A-Za-z_-]+/g, replace: SECRET_PLACEHOLDER }, + // Slack tokens (bot/user + app-level) + incoming webhooks + { pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: SECRET_PLACEHOLDER }, + { pattern: /xapp-\d-[A-Za-z0-9-]{10,}/g, replace: SECRET_PLACEHOLDER }, + { + pattern: /https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, + replace: SECRET_PLACEHOLDER, + }, + // Stripe + { + pattern: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b/g, + replace: SECRET_PLACEHOLDER, + }, + // npm + { pattern: /npm_[A-Za-z0-9]{36}/g, replace: SECRET_PLACEHOLDER }, + // PyPI upload token + { + pattern: /pypi-AgEIcHlwaS[A-Za-z0-9_-]{50,}/g, + replace: SECRET_PLACEHOLDER, + }, + // SendGrid + { + pattern: /SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/g, + replace: SECRET_PLACEHOLDER, + }, + + // ── Structured tokens ──────────────────────────────────────────────────── + // JWT (header.payload.signature) + { + pattern: /eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, + replace: SECRET_PLACEHOLDER, + }, + // PEM private key blocks (RSA/EC/OPENSSH/DSA/plain + PGP "...KEY BLOCK") + { + pattern: + /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----[\s\S]+?-----END (?:[A-Z0-9 ]+ )?PRIVATE KEY(?: BLOCK)?-----/g, + replace: SECRET_PLACEHOLDER, + }, + + // ── Structural / contextual (sensitive NAME + assignment) ───────────────── + // KEY=value or "key": "value" where the name looks sensitive. Keep the name + // and separator ($1), redact the value. Notes: + // - (?![A-Za-z0-9]) after the keyword requires a component boundary, so + // `token` matches `api_token`/`mytoken` but NOT `tokenizer`/`tokens`. + // - the value may start with an auth scheme word (Bearer/Token/Basic) so a + // `X-Api-Key: Bearer ` shape redacts the credential, not just "Bearer". + // - value excludes & and ; so query-string params past the secret survive. + // - requires a 6+ char value so short non-secret values are not touched. + { + pattern: + /\b([A-Za-z0-9_.-]*(?:API[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|AUTH[_-]?TOKEN|CLIENT[_-]?SECRET)(?![A-Za-z0-9])(?:[_.-][A-Za-z0-9]+)*["']?\s*[:=]\s*["']?)(?:(?:bearer|token|basic)\s+)?[^\s"'&;]{6,}/gi, + replace: `$1${SECRET_PLACEHOLDER}`, + }, + // Authorization / API-key headers. Keep the header name + separator ($1$2) + // and an optional scheme ($3); redact the credential. + { + pattern: + /\b(authorization|x-api-key|x-auth-token)(["']?\s*[:=]\s*["']?)(bearer\s+|token\s+|basic\s+)?[A-Za-z0-9._~+/-]{8,}=*/gi, + replace: `$1$2$3${SECRET_PLACEHOLDER}`, + }, + // Bare "Bearer " (any case; the scheme word is preserved via $1). + { + pattern: /\b(Bearer\s+)[A-Za-z0-9._~+/-]{10,}=*/gi, + replace: `$1${SECRET_PLACEHOLDER}`, + }, + // Credentials embedded in URLs: proto://user:PASS@host -> redact PASS only. + // Username is optional so proto://:PASS@host (empty user) is still covered. + { + pattern: /\b([a-z][a-z0-9+.-]*:\/\/[^:@/\s]*:)[^@/\s]+(@)/gi, + replace: `$1${SECRET_PLACEHOLDER}$2`, + }, +]; + +/** + * Build an anonymizer pre-loaded with {@link DEFAULT_SECRET_RULES} suitable for + * passing to `new Client({ anonymizer })`. It redacts detected secrets from run + * inputs, outputs, and metadata client-side, before they are uploaded. + * + * @param options.extraRules - Additional rules appended after the defaults. + * @param options.maxDepth - Max recursion depth (default 24; higher than + * `createAnonymizer`'s default of 10 because traced payloads nest deeply, + * e.g. `messages[].content[].args`). + * + * @example + * ```ts + * import { Client } from "langsmith"; + * import { createSecretAnonymizer } from "langsmith/anonymizer"; + * + * const client = new Client({ anonymizer: createSecretAnonymizer() }); + * ``` + */ +export function createSecretAnonymizer(options?: { + extraRules?: StringNodeRule[]; + maxDepth?: number; +}) { + const rules = [...DEFAULT_SECRET_RULES, ...(options?.extraRules ?? [])]; + return createAnonymizer(rules, { maxDepth: options?.maxDepth ?? 24 }); +} diff --git a/src/lib/client.ts b/src/lib/client.ts new file mode 100644 index 0000000..841651c --- /dev/null +++ b/src/lib/client.ts @@ -0,0 +1,8069 @@ +import * as uuid from "./utils/uuid/src/index.js"; +import type { OTELContext } from "./experimental/otel/types.js"; +import { + LangSmithToOTELTranslator, + SerializedRunOperation, +} from "./experimental/otel/translator.js"; +import { + getDefaultOTLPTracerComponents, + getOTELTrace, + getOTELContext, +} from "./singletons/otel.js"; +import { AsyncCaller, AsyncCallerParams } from "./utils/async_caller.js"; +import { + ComparativeExperiment, + DataType, + Dataset, + DatasetDiffInfo, + DatasetShareSchema, + Example, + ExampleCreate, + ExampleUpdate, + ExampleUpdateWithoutId, + Feedback, + FeedbackConfig, + FeedbackIngestToken, + KVMap, + LangChainBaseMessage, + LangSmithSettings, + LikePromptResponse, + ListCommitsResponse, + ListPromptsResponse, + Prompt, + PromptCommit, + PromptSortField, + Run, + RunCreate, + RunUpdate, + ScoreType, + TimeDelta, + TracerSession, + TracerSessionResult, + ValueType, + AnnotationQueue, + RunWithAnnotationQueueInfo, + Attachments, + UploadExamplesResponse, + UpdateExamplesResponse, + RawExample, + AttachmentInfo, + AttachmentData, + DatasetVersion, + AnnotationQueueWithDetails, + AnnotationQueueRubricItem, + RunKey, + FeedbackConfigSchema, + AgentContext, + SkillContext, + Entry, + DirectoryCommitResponse, + HubRepoType, +} from "./schemas.js"; +import { + convertLangChainMessageToExample, + isLangChainMessage, +} from "./utils/messages.js"; +import { + getEnvironmentVariable, + getLangSmithEnvVarsMetadata, + getLangSmithEnvironmentVariable, + getRuntimeEnvironment, + getEnv, + resolveTracingMode, + type TracingMode, +} from "./utils/env.js"; + +import { EvaluationResult, EvaluationResults } from "./evaluation/evaluator.js"; +import { __version__ } from "./index.js"; +import { Langsmith as OpenAPILangsmith } from "../client.js"; +import { OnlineEvaluators as Evaluators } from "../resources/online-evaluators.js"; +import { Runs as OpenAPIRuns } from "../resources/runs.js"; +import { Sandboxes } from "../resources/sandboxes/sandboxes.js"; +import { Datasets } from "../resources/datasets/datasets.js"; +import { AnnotationQueues } from "../resources/annotation-queues/annotation-queues.js"; +import { Threads } from "../resources/threads.js"; +import { Traces } from "../resources/traces.js"; +import { Public } from "../resources/public/public.js"; +import { assertUuid } from "./utils/_uuid.js"; +import { isSampledById } from "./utils/sampling.js"; +import { warnOnce } from "./utils/warn.js"; +import { getQueryBackend, QueryBackend } from "./utils/v2_migration.js"; +import { parseHubIdentifier } from "./utils/prompts.js"; +import { + raiseForStatus, + isLangSmithNotFoundError, + isLangSmithConflictError, +} from "./utils/error.js"; +import { + PromptCache, + promptCacheSingleton, +} from "./utils/prompt_cache/index.js"; +import * as fsUtils from "./utils/fs.js"; +import { + _shouldStreamForGlobalFetchImplementation, + _getFetchImplementation, +} from "./singletons/fetch.js"; +import { + DEFAULT_API_URL, + type ProfileAuthHeader, + ProfileAuth, + hasValue, + loadProfileClientConfig, +} from "./utils/profiles.js"; + +import { + serialize as serializePayloadForTracing, + estimateSerializedSize, +} from "./utils/fast-safe-stringify/index.js"; +import { + getSharedSerializeWorker, + hasLargeString, + SerializeWorker, +} from "./utils/serialize_worker.js"; + +function assertPullPublicPromptAllowed( + promptIdentifier: string, + dangerouslyPullPublicPrompt?: boolean, +): void { + const [owner] = parseHubIdentifier(promptIdentifier); + if (owner !== "-" && !dangerouslyPullPublicPrompt) { + throw new Error( + "Pulling a public prompt by owner/name is disabled by default because prompts may contain untrusted serialized LangChain objects. If you trust this prompt, set `dangerouslyPullPublicPrompt: true` to acknowledge the risk.", + ); + } +} + +/** + * Catches timestamps without a timezone suffix. + */ +function _ensureUTCTimestamp( + ts: string | number | undefined, +): string | number | undefined { + if ( + typeof ts === "string" && + ts.length > 0 && + !ts.includes("Z") && + !ts.includes("+") && + !ts.includes("-", 10) + ) { + return ts + "Z"; + } + return ts; +} + +function _normalizeRunTimestamps(run: T): T { + return { + ...run, + start_time: _ensureUTCTimestamp(run.start_time), + end_time: _ensureUTCTimestamp(run.end_time), + }; +} + +export interface ClientConfig { + apiUrl?: string; + apiKey?: string; + callerOptions?: AsyncCallerParams; + timeout_ms?: number; + webUrl?: string; + /** + * A function applied for masking serialized run inputs, outputs and + * metadata, before sending to the API. Can be called with raw inputs, raw + * outputs, raw metadata, or a nested `{ error: string }` object for errors. + * + * If a `hideInputs`, `hideOutputs` or `hideMetadata` function is present, + * the client will call it instead of the anonymizer as appropriate. + */ + anonymizer?: (values: KVMap) => KVMap | Promise; + hideInputs?: boolean | ((inputs: KVMap) => KVMap | Promise); + hideOutputs?: boolean | ((outputs: KVMap) => KVMap | Promise); + hideMetadata?: boolean | ((metadata: KVMap) => KVMap | Promise); + /** + * Whether to omit runtime information from traced runs. + * If true, runtime information (SDK version, platform, etc.) and + * LangChain environment variable metadata will not be stored in runs. + * Defaults to false. + */ + omitTracedRuntimeInfo?: boolean; + autoBatchTracing?: boolean; + /** Maximum size of a batch of runs in bytes. */ + batchSizeBytesLimit?: number; + /** Maximum number of operations to batch in a single request. */ + batchSizeLimit?: number; + /** + * Maximum total memory (in bytes) for both the AutoBatchQueue and batchIngestCaller queue. + * When exceeded, runs/batches are dropped. Defaults to 1GB. + */ + maxIngestMemoryBytes?: number; + blockOnRootRunFinalization?: boolean; + traceBatchConcurrency?: number; + fetchOptions?: RequestInit; + /** + * Whether to require manual .flush() calls before sending traces. + * Useful if encountering network rate limits at trace high volumes. + */ + manualFlushMode?: boolean; + tracingSamplingRate?: number; + /** + * Enable debug mode for the client. If set, all sent HTTP requests will be logged. + */ + debug?: boolean; + /** + * The workspace ID. Required for org-scoped API keys. + */ + workspaceId?: string; + /** + * Custom fetch implementation. Useful for testing. + */ + fetchImplementation?: typeof fetch; + /** + * Disable prompt caching for this client. + * By default, prompt caching is enabled globally. + */ + disablePromptCache?: boolean; + + /** + * Where to send traces. One of: + * + * - `"langsmith"` (default) — LangSmith REST API only. + * - `"otel"` — OpenTelemetry export only. + * + * Falls back to the `LANGSMITH_TRACING_MODE` env var, then to the + * legacy `OTEL_ENABLED` / `LANGSMITH_OTEL_ENABLED` env vars, then to + * `"langsmith"`. + */ + tracingMode?: TracingMode; + + /** + * Additional HTTP headers to include in all requests. + * These headers will be merged with the default headers (User-Agent, + * x-api-key, x-tenant-id, etc.). Custom headers will not override + * the default required headers. + */ + headers?: Record; + + /** + * @deprecated Use `configureGlobalPromptCache()` to configure caching, or + * `disablePromptCache: true` to disable it. This parameter is deprecated. + * + * Configuration for caching. Can be: + * - `true`: Enable caching with default settings (uses global singleton) + * - `Cache`/`PromptCache` instance: Use custom cache configuration + * - `false`: Disable caching (equivalent to `disablePromptCache: true`) + * + * @example + * ```typescript + * import { Client, Cache, configureGlobalPromptCache } from "langsmith"; + * + * // Enable with defaults + * const client1 = new Client({}); + * + * // Or use custom configuration + * import { configureGlobalPromptCache } from "langsmith"; + * configureGlobalPromptCache({ + * maxSize: 100, + * ttlSeconds: 3600, // 1 hour, or null for infinite TTL + * }); + * const client2 = new Client({}); + * + * // Or disable for a specific client + * const client3 = new Client({ disablePromptCache: true }); + * ``` + */ + cache?: boolean | PromptCache; +} + +/** + * Represents the parameters for listing runs (spans) from the Langsmith server. + */ +interface ListRunsParams { + /** + * The ID or IDs of the project(s) to filter by. + */ + projectId?: string | string[]; + + /** + * The name or names of the project(s) to filter by. + */ + projectName?: string | string[]; + + /** + * The ID of the trace to filter by. + */ + traceId?: string; + /** + * isRoot - Whether to only include root runs. + * */ + isRoot?: boolean; + + /** + * The execution order to filter by. + */ + executionOrder?: number; + + /** + * The ID of the parent run to filter by. + */ + parentRunId?: string; + + /** + * The order by run start date + */ + order?: "asc" | "desc"; + + /** + * The ID of the reference example to filter by. + */ + referenceExampleId?: string; + + /** + * The start time to filter by. + */ + startTime?: Date; + + /** + * The run type to filter by. + */ + runType?: string; + + /** + * Indicates whether to filter by error runs. + */ + error?: boolean; + + /** + * The ID or IDs of the runs to filter by. + */ + id?: string[]; + + /** + * The maximum number of runs to retrieve. + */ + limit?: number; + + /** + * The query string to filter by. + */ + query?: string; + + /** + * The filter string to apply. + * + * Run Filtering: + * Listing runs with query params is useful for simple queries, but doesn't support many common needs, such as filtering by metadata, tags, or other fields. + * LangSmith supports a filter query language to permit more complex filtering operations when fetching runs. This guide will provide a high level overview of the grammar as well as a few examples of when it can be useful. + * If you'd prefer a more visual guide, you can get a taste of the language by viewing the table of runs on any of your projects' pages. We provide some recommended filters to get you started that you can copy and use the SDK. + * + * Grammar: + * The filtering grammar is based on common comparators on fields in the run object. Supported comparators include: + * - gte (greater than or equal to) + * - gt (greater than) + * - lte (less than or equal to) + * - lt (less than) + * - eq (equal to) + * - neq (not equal to) + * - has (check if run contains a tag or metadata json blob) + * - search (search for a substring in a string field) + */ + filter?: string; + + /** + * Filter to apply to the ROOT run in the trace tree. This is meant to be used in conjunction with the regular + * `filter` parameter to let you filter runs by attributes of the root run within a trace. Example is filtering by + * feedback assigned to the trace. + */ + traceFilter?: string; + + /** + * Filter to apply to OTHER runs in the trace tree, including sibling and child runs. This is meant to be used in + * conjunction with the regular `filter` parameter to let you filter runs by attributes of any run within a trace. + */ + treeFilter?: string; + /** + * The values to include in the response. + * + * Note: The 'child_run_ids' value is deprecated and will be removed in a future version. + * This field is no longer populated by the API. + */ + select?: string[]; +} + +interface GroupRunsParams { + /** + * The ID or IDs of the project(s) to filter by. + */ + projectId?: string; + + /** + * The ID or IDs of the project(s) to filter by. + */ + projectName?: string; + + /** + * @example "conversation" + */ + groupBy: string; + + /** + * The filter string to apply. + * + * Run Filtering: + * Listing runs with query params is useful for simple queries, but doesn't support many common needs, such as filtering by metadata, tags, or other fields. + * LangSmith supports a filter query language to permit more complex filtering operations when fetching runs. This guide will provide a high level overview of the grammar as well as a few examples of when it can be useful. + * If you'd prefer a more visual guide, you can get a taste of the language by viewing the table of runs on any of your projects' pages. We provide some recommended filters to get you started that you can copy and use the SDK. + * + * Grammar: + * The filtering grammar is based on common comparators on fields in the run object. Supported comparators include: + * - gte (greater than or equal to) + * - gt (greater than) + * - lte (less than or equal to) + * - lt (less than) + * - eq (equal to) + * - neq (not equal to) + * - has (check if run contains a tag or metadata json blob) + * - search (search for a substring in a string field) + */ + filter?: string; + + /** + * The start time to filter by. + */ + startTime?: Date; + + /** + * The end time to filter by. + */ + endTime?: Date; + + /** + * The maximum number of runs to retrieve. + */ + limit?: number; + + /** + * The maximum number of runs to retrieve. + */ + offset?: number; +} + +export interface ReadThreadParams { + threadId: string; + projectId?: string; + projectName?: string; + isRoot?: boolean; + limit?: number; + filter?: string; + order?: "asc" | "desc"; +} + +export interface ListThreadsParams { + projectId?: string; + projectName?: string; + limit?: number; + offset?: number; + filter?: string; + startTime?: Date; + isRoot?: boolean; +} + +interface UploadCSVParams { + csvFile: Blob; + fileName: string; + inputKeys: string[]; + outputKeys: string[]; + description?: string; + dataType?: DataType; + name?: string; +} + +interface feedback_source { + type: string; + metadata?: KVMap; +} + +interface FeedbackCreate { + id: string; + run_id: string | null; + trace_id?: string; + key: string; + score?: ScoreType; + value?: ValueType; + correction?: object | null; + comment?: string | null; + feedback_source?: feedback_source | KVMap | null; + feedbackConfig?: FeedbackConfig; + session_id?: string; + start_time?: number | string; + comparative_experiment_id?: string; + extend_trace_retention?: boolean; +} + +interface FeedbackUpdate { + score?: ScoreType; + value?: ValueType; + correction?: object | null; + comment?: string | null; +} + +type DefaultClientConfig = { + apiUrl: string; + apiUrlSource: string; + apiKey?: string; + webUrl?: string; + hideInputs?: boolean; + hideOutputs?: boolean; + hideMetadata?: boolean; + workspaceId?: string; + oauthAccessToken?: string; + oauthRefreshToken?: string; + profileAuth?: ProfileAuth; +}; + +interface CreateRunParams { + name: string; + inputs: KVMap; + run_type: string; + id?: string; + start_time?: number | string; + end_time?: number | string; + extra?: KVMap; + error?: string; + serialized?: object; + outputs?: KVMap; + reference_example_id?: string; + child_runs?: RunCreate[]; + parent_run_id?: string; + project_name?: string; + revision_id?: string; + trace_id?: string; + dotted_order?: string; + attachments?: Attachments; +} + +interface UpdateRunParams extends RunUpdate { + id?: string; +} + +interface ProjectOptions { + projectName?: string; + projectId?: string; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type RecordStringAny = Record; + +export type FeedbackSourceType = "model" | "api" | "app"; + +export type CreateFeedbackOptions = { + /** The metric name, tag, or aspect to provide feedback on. */ + key: string; + score?: ScoreType; + value?: ValueType; + correction?: object; + comment?: string; + sourceInfo?: object; + feedbackSourceType?: FeedbackSourceType; + feedbackConfig?: FeedbackConfig; + sourceRunId?: string; + feedbackId?: string; + /** The trace the run belongs to. Omit for a root run. */ + traceId?: string; + comparativeExperimentId?: string; + /** + * The run's start time, ISO string or epoch ms. Better performance if provided. + */ + startTime?: number | string; + /** If false, create feedback without extending the trace's retention tier. */ + extendTraceRetention?: boolean; +}; + +/** @deprecated Pass all params within an object and populate sessionId. */ +export type CreateFeedbackLegacyOptions = Omit & { + /** @deprecated This option is no longer used. */ + eager?: boolean; + /** + * The session (project) ID of the run. Required for run-level feedback; + * omitting it is deprecated. See + * https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#feedback-create + */ + sessionId?: string; + /** The project (or experiment) to provide feedback on, for session-level feedback. */ + projectId?: string; +}; + +export type CreateFeedbackParams = CreateFeedbackOptions & + ( + | { + /** The run to provide feedback on. */ + runId: string; + /** The session (project) ID of the run. */ + sessionId: string; + projectId?: never; + } + | { + runId?: null; + /** The project (or experiment) to provide feedback on. */ + projectId: string; + sessionId?: never; + } + ); + +export type CreateExampleOptions = { + /** The ID of the dataset to create the example in. */ + datasetId?: string; + /** The name of the dataset to create the example in (if dataset ID is not provided). */ + datasetName?: string; + /** The creation date of the example. */ + createdAt?: Date; + /** A unique identifier for the example. */ + exampleId?: string; + /** Additional metadata associated with the example. */ + metadata?: KVMap; + /** The split(s) to assign the example to. */ + split?: string | string[]; + /** The ID of the source run associated with this example. */ + sourceRunId?: string; + /** Whether to use the inputs and outputs from the source run. */ + useSourceRunIO?: boolean; + /** Which attachments from the source run to use. */ + useSourceRunAttachments?: string[]; + /** Attachments for the example */ + attachments?: Attachments; +}; + +export type CreateProjectParams = { + projectName: string; + description?: string | null; + metadata?: RecordStringAny | null; + upsert?: boolean; + projectExtra?: RecordStringAny | null; + referenceDatasetId?: string | null; + numExamples?: number | null; + numRepetitions?: number | null; + evaluatorKeys?: string[] | null; + tagValueIds?: string[] | null; +}; + +type AutoBatchQueueItem = { + action: "create" | "update"; + item: RunCreate | RunUpdate; + otelContext?: OTELContext; + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + size?: number; +}; + +type MultipartPart = { + name: string; + payload: Blob; +}; + +type Thread = { + filter: string; + count: number; + total_tokens: number; + total_cost: number | null; + min_start_time: string; + max_start_time: string; + latency_p50: number; + latency_p99: number; + feedback_stats: any | null; + thread_id: string; + first_inputs: string; + last_outputs: string; + last_error: string | null; +}; + +/** Item returned by {@link Client.listThreads}. */ +export interface ListThreadsItem extends Thread { + runs: Run[]; +} + +/** Index creates by id (last wins); warns when one id goes to two projects. */ +function indexRunCreatesById(creates: RunCreate[]): Record { + const byId: Record = {}; + for (const run of creates) { + if (!run.id) { + continue; + } + const previous = byId[run.id]; + if (previous !== undefined && previous.session_name !== run.session_name) { + console.warn( + `LangSmith run ${run.id} was queued for two projects with the same id ` + + `(${JSON.stringify(previous.session_name)} and ` + + `${JSON.stringify( + run.session_name, + )}); only one of the projects will ` + + "keep the run. This usually means two write replicas both keep the " + + "original run ids, for example a replica marked `primary` plus one " + + "for the run's own project.", + ); + } + byId[run.id] = run; + } + return byId; +} + +export function mergeRuntimeEnvIntoRun( + run: T, + cachedEnvVars?: Record, + omitTracedRuntimeInfo?: boolean, + tracingSampleRate?: number, +): T { + if (omitTracedRuntimeInfo) { + return run; + } + const runtimeEnv = getRuntimeEnvironment(); + const envVars = cachedEnvVars ?? getLangSmithEnvVarsMetadata(); + const extra = run.extra ?? {}; + const metadata = extra.metadata; + run.extra = { + ...extra, + runtime: { + ...runtimeEnv, + ...extra?.runtime, + }, + metadata: { + ...envVars, + ...(envVars.revision_id || ("revision_id" in run && run.revision_id) + ? { + revision_id: + ("revision_id" in run ? run.revision_id : undefined) ?? + envVars.revision_id, + } + : {}), + ...metadata, + ...(tracingSampleRate !== undefined + ? { ls_tracing_sample_rate: tracingSampleRate } + : {}), + }, + }; + return run; +} + +const getTracingSamplingRate = (configRate?: number) => { + const samplingRateStr = + configRate?.toString() ?? + getLangSmithEnvironmentVariable("TRACING_SAMPLING_RATE"); + if (samplingRateStr === undefined) { + return undefined; + } + const samplingRate = parseFloat(samplingRateStr); + if (samplingRate < 0 || samplingRate > 1) { + throw new Error( + `LANGSMITH_TRACING_SAMPLING_RATE must be between 0 and 1 if set. Got: ${samplingRate}`, + ); + } + return samplingRate; +}; + +// utility functions +const isLocalhost = (url: string): boolean => { + const strippedUrl = url.replace("http://", "").replace("https://", ""); + const hostname = strippedUrl.split("/")[0].split(":")[0]; + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" + ); +}; + +async function toArray(iterable: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iterable) { + result.push(item); + } + return result; +} + +function trimQuotes(str?: string): string | undefined { + if (str === undefined) { + return undefined; + } + return str + .trim() + .replace(/^"(.*)"$/, "$1") + .replace(/^'(.*)'$/, "$1"); +} + +const handle429 = async (response?: Response) => { + if (response?.status === 429) { + const retryAfter = + parseInt(response.headers.get("retry-after") ?? "10", 10) * 1000; + if (retryAfter > 0) { + await new Promise((resolve) => setTimeout(resolve, retryAfter)); + // Return directly after calling this check + return true; + } + } + // Fall back to existing status checks + return false; +}; + +function _formatFeedbackScore(score?: ScoreType): ScoreType | undefined { + if (typeof score === "number") { + // Truncate at 4 decimal places + return Number(score.toFixed(4)); + } + return score; +} + +export function _checkBackendVersion( + backendVersion: string, + minVersion: string, +): void { + if (!backendVersion) { + // /info was unreachable (or skipped): nothing to compare against. + return; + } + const parse = (v: string) => v.split(".").map((s) => parseInt(s, 10)); + const [maj, min, pat] = parse(backendVersion); + const [rMaj, rMin, rPat] = parse(minVersion); + if ( + isNaN(maj) || + isNaN(min) || + isNaN(pat) || + isNaN(rMaj) || + isNaN(rMin) || + isNaN(rPat) + ) { + console.warn( + `[LANGSMITH]: Could not parse backend version ${JSON.stringify( + backendVersion, + )} for compatibility check.`, + ); + return; + } + if ( + maj < rMaj || + (maj === rMaj && min < rMin) || + (maj === rMaj && min === rMin && pat < rPat) + ) { + console.warn( + `[LANGSMITH]: Backend version ${JSON.stringify( + backendVersion, + )} is older than ` + + `the minimum version required by this SDK (${JSON.stringify( + minVersion, + )}). ` + + "Some features may not work as expected. See " + + "https://docs.langchain.com/langsmith/smithdb-sdk-migration", + ); + } +} + +export const DEFAULT_UNCOMPRESSED_BATCH_SIZE_LIMIT_BYTES = 24 * 1024 * 1024; + +/** Default maximum memory (1GB) for queue size limits. */ +export const DEFAULT_MAX_SIZE_BYTES = 1024 * 1024 * 1024; // 1GB + +const SERVER_INFO_REQUEST_TIMEOUT_MS = 10000; + +/** Maximum number of operations to batch in a single request. */ +const DEFAULT_BATCH_SIZE_LIMIT = 100; + +/** + * Reject header names or values that could alter a request's framing. + * + * `Headers` does this for us, and does it the same way `fetch` will. + */ +function assertValidHeader(name: string, value: string): void { + new Headers({ [name]: value }); +} + +/** Reject a malformed header up front, before it can reach a request. */ +function assertValidHeaders(headers?: Record): void { + for (const [name, value] of Object.entries(headers ?? {})) { + assertValidHeader(name, value); + } +} + +/** + * Fold a `HeadersInit` into a plain object so it can be spread. + * + * Names are matched case-insensitively with the last value winning, keeping the + * first spelling seen. A plain object would instead keep both spellings, which + * `Headers` later joins into a `"first, second"` list rather than an override. + * Every name and value is validated on the way through. + */ +function normalizeHeaders( + headers?: HeadersInit | Record, +): Record { + if (!headers) return {}; + const entries: Array<[string, string]> = + headers instanceof Headers + ? [...headers.entries()] + : Array.isArray(headers) + ? headers.map(([name, value]) => [name, value]) + : Object.entries(headers); + + const normalized: Record = {}; + const nameByLower = new Map(); + for (const [name, value] of entries) { + assertValidHeader(name, value); + const lowerName = name.toLowerCase(); + const existingName = nameByLower.get(lowerName); + if (existingName === undefined) { + nameByLower.set(lowerName, name); + normalized[name] = value; + } else { + normalized[existingName] = value; + } + } + return normalized; +} + +/** + * Merge the caller's two header channels, then drop the `reserved` names. + * + * `overrides` wins, matched case-insensitively so a header supplied through both + * channels is overridden rather than turned into a `"first, second"` list. + */ +function mergeCallerHeaders( + base: Record, + overrides: Record, + reserved: ReadonlySet, +): Record { + const merged: Record = { ...base }; + const nameByLower = new Map( + Object.keys(merged).map((name) => [name.toLowerCase(), name]), + ); + for (const [name, value] of Object.entries(overrides)) { + const lowerName = name.toLowerCase(); + merged[nameByLower.get(lowerName) ?? name] = value; + } + for (const name of Object.keys(merged)) { + if (reserved.has(name.toLowerCase())) { + delete merged[name]; + } + } + return merged; +} + +export class AutoBatchQueue { + items: { + action: "create" | "update"; + payload: RunCreate | RunUpdate; + otelContext?: OTELContext; + itemPromiseResolve: () => void; + itemPromise: Promise; + size: number; + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + }[] = []; + + sizeBytes = 0; + + private maxSizeBytes: number; + + constructor(maxSizeBytes?: number) { + this.maxSizeBytes = maxSizeBytes ?? DEFAULT_MAX_SIZE_BYTES; + } + + peek() { + return this.items[0]; + } + + push(item: AutoBatchQueueItem): Promise { + let itemPromiseResolve; + const itemPromise = new Promise((resolve) => { + // Setting itemPromiseResolve is synchronous with promise creation: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/Promise + itemPromiseResolve = resolve; + }); + // Use a cheap structural estimate for soft memory accounting (queue size + // limit and downstream async caller memory tracking). The exact + // serialization still happens later, off the hot path, when the batch is + // assembled for sending. + const size = estimateSerializedSize(item.item).size; + + // Check if adding this item would exceed the size limit + // Allow the run if the queue is empty (to support large single traces) + if (this.sizeBytes + size > this.maxSizeBytes && this.items.length > 0) { + console.warn( + `AutoBatchQueue size limit (${this.maxSizeBytes} bytes) exceeded. Dropping run with id: ${item.item.id}. ` + + `Current queue size: ${this.sizeBytes} bytes, attempted addition: ${size} bytes.`, + ); + // Resolve immediately to avoid blocking caller + itemPromiseResolve!(); + return itemPromise; + } + + this.items.push({ + action: item.action, + payload: item.item, + otelContext: item.otelContext, + apiKey: item.apiKey, + apiUrl: item.apiUrl, + workspaceId: item.workspaceId, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + itemPromiseResolve: itemPromiseResolve!, + itemPromise, + size, + }); + this.sizeBytes += size; + return itemPromise; + } + + pop({ + upToSizeBytes, + upToSize, + }: { + upToSizeBytes: number; + upToSize: number; + }): [AutoBatchQueueItem[], () => void] { + if (upToSizeBytes < 1) { + throw new Error("Number of bytes to pop off may not be less than 1."); + } + const popped: typeof this.items = []; + let poppedSizeBytes = 0; + // Pop items until we reach or exceed the size limit + while ( + poppedSizeBytes + (this.peek()?.size ?? 0) < upToSizeBytes && + this.items.length > 0 && + popped.length < upToSize + ) { + const item = this.items.shift(); + if (item) { + popped.push(item); + poppedSizeBytes += item.size; + this.sizeBytes -= item.size; + } + } + // If there is an item on the queue we were unable to pop, + // just return it as a single batch. + if (popped.length === 0 && this.items.length > 0) { + const item = this.items.shift()!; + popped.push(item); + poppedSizeBytes += item.size; + this.sizeBytes -= item.size; + } + return [ + popped.map((it) => ({ + action: it.action, + item: it.payload, + otelContext: it.otelContext, + apiKey: it.apiKey, + apiUrl: it.apiUrl, + workspaceId: it.workspaceId, + size: it.size, + })), + () => popped.forEach((it) => it.itemPromiseResolve()), + ]; + } +} + +export class Client implements LangSmithTracingClientInterface { + private apiKey?: string; + + private apiUrl: string; + + private webUrl?: string; + + private workspaceId?: string; + + private caller: AsyncCaller; + + private batchIngestCaller: AsyncCaller; + + private timeout_ms: number; + + private _tenantId: string | null = null; + + private hideInputs?: boolean | ((inputs: KVMap) => KVMap | Promise); + + private hideOutputs?: boolean | ((outputs: KVMap) => KVMap | Promise); + + private hideMetadata?: + | boolean + | ((metadata: KVMap) => KVMap | Promise); + + private anonymizer?: (values: KVMap) => KVMap | Promise; + + private omitTracedRuntimeInfo?: boolean; + + private tracingSampleRate?: number; + + private autoBatchTracing = true; + + private autoBatchQueue: AutoBatchQueue; + + private autoBatchTimeout: ReturnType | undefined; + + private autoBatchAggregationDelayMs = 250; + + private batchSizeBytesLimit?: number; + + private batchSizeLimit?: number; + + /** `config.fetchOptions` without its `headers`, which are merged separately. */ + private fetchOptions: Omit; + + private _fetchOptionsHeaders: Record = {}; + + private _openAPIClient?: OpenAPILangsmith; + + private _openAPIClientSignature?: string; + + private settings: Promise | null; + + private blockOnRootRunFinalization = + getEnvironmentVariable("LANGSMITH_TRACING_BACKGROUND") === "false"; + + private traceBatchConcurrency = 5; + + private _serverInfo: RecordStringAny | undefined; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private _getServerInfoPromise?: Promise>; + + private _stainlessVersionsChecked = new Set(); + + private manualFlushMode = false; + + private _serializeWorker?: SerializeWorker | null; + + /** + * Tracks in-flight drainAutoBatchQueue promises so awaitPendingTraceBatches + * can wait on them even if the flush involves async work (worker-thread + * serialize) that hasn't yet registered with batchIngestCaller.queue. + */ + private _pendingDrains = new Set>(); + + private langSmithToOTELTranslator?: LangSmithToOTELTranslator; + + private _tracingMode: TracingMode = "langsmith"; + + public get tracingMode(): TracingMode { + return this._tracingMode; + } + + private fetchImplementation?: typeof fetch; + + private cachedLSEnvVarsForMetadata?: Record; + + private _promptCache?: PromptCache; + + private profileAuth?: ProfileAuth; + + private get _fetch(): typeof fetch { + const fetchImplementation = + this.fetchImplementation || _getFetchImplementation(this.debug); + return (async (input: RequestInfo | URL, init?: RequestInit) => { + let authHeader: ProfileAuthHeader | undefined; + const profileManagedAuthorization = + this.getProfileManagedAuthorizationHeader(init); + if (this.apiKey !== undefined) { + authHeader = { name: "x-api-key", value: `${this.apiKey}` }; + } else if ( + !this.hasExplicitAuthHeader(init, profileManagedAuthorization) + ) { + authHeader = await this.profileAuth?.getAuthHeader( + fetchImplementation, + init?.signal, + ); + } + return fetchImplementation( + input, + this.applyCurrentAuthHeaders( + init, + authHeader, + profileManagedAuthorization, + ), + ); + }) as typeof fetch; + } + + private getProfileManagedAuthorizationHeader( + init?: RequestInit, + ): string | undefined { + if (!init?.headers || !this.profileAuth) { + return undefined; + } + const authorization = new Headers(init.headers).get("Authorization"); + if (!hasValue(authorization)) { + return undefined; + } + return this.profileAuth.isProfileAuthorizationHeader(authorization ?? "") + ? authorization ?? undefined + : undefined; + } + + private isProfileManagedAuthorizationHeader( + value: string, + profileManagedAuthorization?: string, + ): boolean { + return ( + value === profileManagedAuthorization || + this.profileAuth?.isProfileAuthorizationHeader(value) === true + ); + } + + private hasExplicitAuthHeader( + init?: RequestInit, + profileManagedAuthorization?: string, + ): boolean { + if (!init?.headers) { + return false; + } + const headers = new Headers(init.headers); + if (hasValue(headers.get("x-api-key"))) { + return true; + } + const authorization = headers.get("Authorization"); + if (!hasValue(authorization)) { + return false; + } + return !this.isProfileManagedAuthorizationHeader( + authorization ?? "", + profileManagedAuthorization, + ); + } + + private applyCurrentAuthHeaders( + init?: RequestInit, + authHeader?: ProfileAuthHeader, + profileManagedAuthorization?: string, + ): RequestInit | undefined { + if (!authHeader) { + return init; + } + const applyAuth = (headers: Headers): Headers => { + if (this.apiKey !== undefined && authHeader.name === "x-api-key") { + headers.delete("Authorization"); + if (!headers.has("x-api-key")) { + headers.set("x-api-key", authHeader.value); + } + return headers; + } + if (authHeader.name === "Authorization") { + if (hasValue(headers.get("x-api-key"))) { + return headers; + } + const authorization = headers.get("Authorization"); + if ( + hasValue(authorization) && + !this.isProfileManagedAuthorizationHeader( + authorization ?? "", + profileManagedAuthorization, + ) + ) { + return headers; + } + headers.set("Authorization", authHeader.value); + return headers; + } + const authorization = headers.get("Authorization"); + if ( + hasValue(authorization) && + !this.isProfileManagedAuthorizationHeader( + authorization ?? "", + profileManagedAuthorization, + ) + ) { + return headers; + } + if (hasValue(authorization)) { + headers.delete("Authorization"); + } + if (!headers.has("x-api-key")) { + headers.set("x-api-key", authHeader.value); + } + return headers; + }; + if (!init) { + return { + headers: { [authHeader.name]: authHeader.value }, + }; + } + if (init.headers instanceof Headers) { + return { ...init, headers: applyAuth(new Headers(init.headers)) }; + } + if (Array.isArray(init.headers)) { + return { ...init, headers: applyAuth(new Headers(init.headers)) }; + } + const headers = { + ...((init.headers ?? {}) as Record), + }; + const getHeaderKey = (name: string) => + Object.keys(headers).find((key) => key.toLowerCase() === name); + const getHeader = (name: string) => { + const key = getHeaderKey(name); + return key ? headers[key] : undefined; + }; + const hasApiKey = hasValue(getHeader("x-api-key")); + const authorization = getHeader("authorization"); + const hasExplicitAuthorization = + hasValue(authorization) && + !this.isProfileManagedAuthorizationHeader( + authorization ?? "", + profileManagedAuthorization, + ); + + if (this.apiKey !== undefined && authHeader.name === "x-api-key") { + const authorizationKey = getHeaderKey("authorization"); + if (authorizationKey) { + delete headers[authorizationKey]; + } + if (!hasApiKey) { + headers["x-api-key"] = authHeader.value; + } + return { ...init, headers }; + } + if (authHeader.name === "Authorization") { + if (!hasApiKey && !hasExplicitAuthorization) { + const authorizationKey = getHeaderKey("authorization"); + if (authorizationKey && authorizationKey !== "Authorization") { + delete headers[authorizationKey]; + } + headers.Authorization = authHeader.value; + } + return { ...init, headers }; + } + if (!hasExplicitAuthorization) { + const authorizationKey = getHeaderKey("authorization"); + if (authorizationKey) { + delete headers[authorizationKey]; + } + if (!hasApiKey) { + headers["x-api-key"] = authHeader.value; + } + } + return { ...init, headers }; + } + + /** + * Serialize a payload for tracing, optionally offloading the work to a + * Node worker thread when the runtime supports worker_threads. + * + * Falls back to synchronous serialization when: + * - manualFlushMode is enabled (serverless: worker boot cost > benefit) + * - worker_threads is unavailable (non-Node runtimes) + * - the payload contains values that can't be structured-cloned across + * threads (functions, non-cloneable class instances, streams, etc.) + * - the worker throws for any other reason + * + * In all fallback cases the returned bytes are identical to the sync path. + */ + private _trackDrain(promise: Promise): void { + this._pendingDrains.add(promise); + promise.finally(() => { + this._pendingDrains.delete(promise); + }); + } + + private async _serializeBody( + payload: unknown, + errorContext: string, + ): Promise> { + if (this.manualFlushMode) { + return serializePayloadForTracing(payload, errorContext); + } + // Shape-aware gate: worker offload pays for itself only when the + // payload is dominated by one or more large strings (V8 can refcount + // those across isolates instead of copying). For structure-heavy + // payloads -- many keys, deep nesting, lots of small strings -- the + // structuredClone walk plus thread-hop cost exceeds the JSON.stringify + // cost we would pay inline, so we fall through to sync serialize. + if (!hasLargeString(payload)) { + return serializePayloadForTracing(payload, errorContext); + } + if (this._serializeWorker === undefined) { + this._serializeWorker = getSharedSerializeWorker(); + } + if (this._serializeWorker === null) { + return serializePayloadForTracing(payload, errorContext); + } + try { + const bytes = await this._serializeWorker.serialize(payload); + if (bytes === null) { + // Worker subsystem unavailable; cache the null to skip re-entry. + this._serializeWorker = null; + return serializePayloadForTracing(payload, errorContext); + } + return bytes; + } catch { + // DataCloneError, worker crash, etc. Fall back silently. + return serializePayloadForTracing(payload, errorContext); + } + } + + private multipartStreamingDisabled = + getLangSmithEnvironmentVariable("DISABLE_MULTIPART_STREAMING") === "true"; + + private _multipartDisabled = false; + + private _runCompressionDisabled = + getLangSmithEnvironmentVariable("DISABLE_RUN_COMPRESSION") === "true"; + + private failedTracesDir: string | undefined; + + private failedTracesMaxBytes: number = 100 * 1024 * 1024; + + private static _fallbackDirsCreated = new Set(); + + private _customHeaders: Record = {}; + + debug = getEnvironmentVariable("LANGSMITH_DEBUG") === "true"; + + constructor(config: ClientConfig = {}) { + const defaultConfig = Client.getDefaultClientConfig(); + + this.tracingSampleRate = getTracingSamplingRate(config.tracingSamplingRate); + this.apiUrl = trimQuotes(config.apiUrl ?? defaultConfig.apiUrl) ?? ""; + if (this.apiUrl.endsWith("/")) { + this.apiUrl = this.apiUrl.slice(0, -1); + } + + const configuredApiKey = trimQuotes(config.apiKey ?? defaultConfig.apiKey); + this.apiKey = hasValue(configuredApiKey) ? configuredApiKey : undefined; + this.profileAuth = + this.apiKey !== undefined ? undefined : defaultConfig.profileAuth; + this.webUrl = trimQuotes(config.webUrl ?? defaultConfig.webUrl); + if (this.webUrl?.endsWith("/")) { + this.webUrl = this.webUrl.slice(0, -1); + } + this.workspaceId = trimQuotes( + config.workspaceId ?? defaultConfig.workspaceId, + ); + this.timeout_ms = config.timeout_ms ?? 90_000; + this.caller = new AsyncCaller({ + ...(config.callerOptions ?? {}), + maxRetries: 4, + debug: config.debug ?? this.debug, + }); + this.traceBatchConcurrency = + config.traceBatchConcurrency ?? this.traceBatchConcurrency; + if (this.traceBatchConcurrency < 1) { + throw new Error("Trace batch concurrency must be positive."); + } + this.debug = config.debug ?? this.debug; + if (this.debug) { + const source = + config.apiUrl != null ? "apiUrl option" : defaultConfig.apiUrlSource; + console.log(`LangSmith API URL ${this.apiUrl} resolved from ${source}`); + } + this.fetchImplementation = config.fetchImplementation; + + // Failed trace dump configuration + this.failedTracesDir = + getLangSmithEnvironmentVariable("FAILED_TRACES_DIR") || undefined; + const failedTracesMb = getLangSmithEnvironmentVariable( + "FAILED_TRACES_MAX_MB", + ); + if (failedTracesMb) { + const n = parseInt(failedTracesMb, 10); + if (Number.isFinite(n) && n > 0) { + this.failedTracesMaxBytes = n * 1024 * 1024; + } + } + + // Use maxIngestMemoryBytes for both queues + const maxMemory = config.maxIngestMemoryBytes ?? DEFAULT_MAX_SIZE_BYTES; + + this.batchIngestCaller = new AsyncCaller({ + maxRetries: 4, + maxConcurrency: this.traceBatchConcurrency, + maxQueueSizeBytes: maxMemory, + ...(config.callerOptions ?? {}), + onFailedResponseHook: handle429, + debug: config.debug ?? this.debug, + }); + + this.hideInputs = + config.hideInputs ?? config.anonymizer ?? defaultConfig.hideInputs; + this.hideOutputs = + config.hideOutputs ?? config.anonymizer ?? defaultConfig.hideOutputs; + this.hideMetadata = + config.hideMetadata ?? config.anonymizer ?? defaultConfig.hideMetadata; + this.anonymizer = config.anonymizer; + + this.omitTracedRuntimeInfo = config.omitTracedRuntimeInfo ?? false; + + this.autoBatchTracing = config.autoBatchTracing ?? this.autoBatchTracing; + this.autoBatchQueue = new AutoBatchQueue(maxMemory); + this.blockOnRootRunFinalization = + config.blockOnRootRunFinalization ?? this.blockOnRootRunFinalization; + this.batchSizeBytesLimit = config.batchSizeBytesLimit; + this.batchSizeLimit = config.batchSizeLimit; + const { headers: fetchOptionsHeaders, ...fetchOptions } = + config.fetchOptions || {}; + this.fetchOptions = fetchOptions; + // Kept apart from `fetchOptions` so they can be merged *under* the required + // headers instead of replacing the whole set at each call site. + this._fetchOptionsHeaders = normalizeHeaders(fetchOptionsHeaders); + // Validated eagerly so malformed input fails at construction, but stored + // as given: `get headers` hands this object back to the caller. + assertValidHeaders(config.headers); + this._customHeaders = config.headers ?? {}; + this.manualFlushMode = config.manualFlushMode ?? this.manualFlushMode; + this._tracingMode = resolveTracingMode(config.tracingMode); + if (this._tracingMode === "otel") { + this.langSmithToOTELTranslator = new LangSmithToOTELTranslator(); + } + // Cache metadata env vars once during construction to avoid repeatedly scanning process.env + this.cachedLSEnvVarsForMetadata = getLangSmithEnvVarsMetadata(); + + // Initialize prompt cache + // Handle backwards compatibility for deprecated `cache` parameter + if (config.cache !== undefined && config.disablePromptCache) { + warnOnce( + "Both 'cache' and 'disablePromptCache' were provided. " + + "The 'cache' parameter is deprecated and will be removed in a future version. " + + "Using 'cache' parameter value.", + ); + } + + if (config.cache !== undefined) { + warnOnce( + "The 'cache' parameter is deprecated and will be removed in a future version. " + + "Use 'configureGlobalPromptCache()' to configure the global cache, or " + + "'disablePromptCache: true' to disable caching for this client.", + ); + // Handle old cache parameter + if (config.cache === false) { + this._promptCache = undefined; + } else if (config.cache === true) { + this._promptCache = promptCacheSingleton; + } else { + // Custom PromptCache instance provided + this._promptCache = config.cache; + } + } else if (!config.disablePromptCache) { + // Use the global singleton instance + this._promptCache = promptCacheSingleton; + } + } + + public static getDefaultClientConfig(): DefaultClientConfig { + const profileConfig = loadProfileClientConfig(); + const envApiKey = getLangSmithEnvironmentVariable("API_KEY"); + const envApiUrl = getLangSmithEnvironmentVariable("ENDPOINT"); + const envWorkspaceId = getLangSmithEnvironmentVariable("WORKSPACE_ID"); + const envAuthSet = hasValue(envApiKey); + const apiUrl = envApiUrl ?? profileConfig.apiUrl ?? DEFAULT_API_URL; + const apiUrlSource = + envApiUrl != null + ? "LANGSMITH_ENDPOINT / LANGCHAIN_ENDPOINT environment variable" + : profileConfig.apiUrl != null + ? "profile config" + : "built-in default"; + const workspaceId = envWorkspaceId ?? profileConfig.workspaceId; + const hideInputs = + getLangSmithEnvironmentVariable("HIDE_INPUTS") === "true"; + const hideOutputs = + getLangSmithEnvironmentVariable("HIDE_OUTPUTS") === "true"; + const hideMetadata = + getLangSmithEnvironmentVariable("HIDE_METADATA") === "true"; + return { + apiUrl: apiUrl, + apiUrlSource, + apiKey: envApiKey, + webUrl: undefined, + hideInputs: hideInputs, + hideOutputs: hideOutputs, + hideMetadata: hideMetadata, + workspaceId: workspaceId, + oauthAccessToken: !envAuthSet + ? profileConfig.oauthAccessToken + : undefined, + oauthRefreshToken: !envAuthSet + ? profileConfig.oauthRefreshToken + : undefined, + profileAuth: !envAuthSet ? profileConfig.profileAuth : undefined, + }; + } + + public getHostUrl(): string { + if (this.webUrl) { + return this.webUrl; + } else if (isLocalhost(this.apiUrl)) { + this.webUrl = "http://localhost:3000"; + return this.webUrl; + } else if (this.apiUrl.endsWith("/api/v1")) { + this.webUrl = this.apiUrl.replace("/api/v1", ""); + return this.webUrl; + } else if ( + this.apiUrl.includes("/api") && + !this.apiUrl.split(".", 1)[0].endsWith("api") + ) { + this.webUrl = this.apiUrl.replace("/api", ""); + return this.webUrl; + } else if (this.apiUrl.split(".", 1)[0].includes("dev")) { + this.webUrl = "https://dev.smith.langchain.com"; + return this.webUrl; + } else if (this.apiUrl.split(".", 1)[0].includes("eu")) { + this.webUrl = "https://eu.smith.langchain.com"; + return this.webUrl; + } else if (this.apiUrl.split(".", 1)[0].includes("aws")) { + this.webUrl = "https://aws.smith.langchain.com"; + return this.webUrl; + } else if (this.apiUrl.split(".", 1)[0].includes("apac")) { + this.webUrl = "https://apac.smith.langchain.com"; + return this.webUrl; + } else if (this.apiUrl.split(".", 1)[0].includes("beta")) { + this.webUrl = "https://beta.smith.langchain.com"; + return this.webUrl; + } else { + this.webUrl = "https://smith.langchain.com"; + return this.webUrl; + } + } + + /** + * The headers this client sets from its own config, which a caller-supplied + * header must not replace. + * + * Only what the client *actually* supplies: passing an explicit `Authorization` + * or `x-api-key` header with no configured credential is a supported way to + * authenticate (see `hasExplicitAuthHeader`), so those must survive. + */ + private get _sdkControlledHeaders(): ReadonlySet { + const names = new Set(); + if (this.apiKey !== undefined) { + names.add("x-api-key"); + } else { + const profileAuthHeader = this.profileAuth?.currentAuthHeader(); + if (profileAuthHeader) { + names.add(profileAuthHeader.name.toLowerCase()); + } + } + if (this.workspaceId) { + names.add("x-tenant-id"); + } + return names; + } + + /** + * Headers supplied by the caller, through either `config.headers` or + * `config.fetchOptions.headers`, with the ones this SDK sets removed. + * + * `_customHeaders` is normalized here rather than at assignment because it is + * public and mutable: `get headers` hands back the caller's own object, so its + * contents can change (and can become malformed) at any point. + */ + private get _callerHeaders(): Record { + return mergeCallerHeaders( + normalizeHeaders(this._customHeaders), + this._fetchOptionsHeaders, + this._sdkControlledHeaders, + ); + } + + private get _mergedHeaders(): { [header: string]: string } { + // Start with caller-supplied headers so they don't override required headers + const headers: { [header: string]: string } = { + "User-Agent": `langsmith-js/${__version__}`, + ...this._callerHeaders, + }; + // Required headers that should not be overridden + if (this.apiKey !== undefined) { + headers["x-api-key"] = `${this.apiKey}`; + } else { + const profileAuthHeader = this.profileAuth?.currentAuthHeader(); + if (profileAuthHeader) { + headers[profileAuthHeader.name] = profileAuthHeader.value; + } + } + if (this.workspaceId) { + headers["x-tenant-id"] = this.workspaceId; + } + return headers; + } + + /** + * The options to build the generated client with: its auth, and the headers + * it should send on every request. + * + * The generated client applies `defaultHeaders` *after* its own auth headers, + * so the ones this SDK sets are already dropped from `_callerHeaders` to keep + * the precedence of `_mergedHeaders`, where required headers win. + */ + private get _openAPIClientOptions(): { + apiKey: string | undefined; + defaultHeaders: Record | undefined; + } { + const headers: Record = { ...this._callerHeaders }; + + // Without this the generated client falls back to its own + // `Langsmith/JS `, so the same client would identify + // itself two different ways depending on which path a call takes. A + // caller-supplied one still wins, matched case-insensitively so the header + // is not sent twice. + if ( + !Object.keys(headers).some((name) => name.toLowerCase() === "user-agent") + ) { + headers["User-Agent"] = `langsmith-js/${__version__}`; + } + + // A caller may authenticate by supplying `x-api-key` themselves. The + // generated client rejects that as a header (its `validateHeaders` requires + // its own `apiKey` to be set), so hand it over as the `apiKey` option and + // drop the header to avoid sending the same value twice. + const callerApiKeyName = Object.keys(headers).find( + (name) => name.toLowerCase() === "x-api-key", + ); + let apiKey = this.apiKey; + if (apiKey === undefined && callerApiKeyName !== undefined) { + apiKey = headers[callerApiKeyName] ?? undefined; + delete headers[callerApiKeyName]; + } + + if (apiKey === undefined && this.workspaceId === undefined) { + // Let the wrapped `fetch` supply auth (profile auth, an explicit + // `Authorization` header, or none at all). + headers["X-API-Key"] = null; + } + + return { + apiKey, + defaultHeaders: Object.keys(headers).length > 0 ? headers : undefined, + }; + } + + /** + * Get or set custom headers for the client. + * Custom headers are merged with default headers (User-Agent, x-api-key, x-tenant-id). + * Custom headers will not override the default required headers. + */ + public get headers(): Record { + return this._customHeaders; + } + + public set headers(value: Record | undefined) { + assertValidHeaders(value); + this._customHeaders = value ?? {}; + } + + private _getOpenAPIBaseUrl(): string { + const url = this.apiUrl.replace(/\/$/, ""); + for (const suffix of ["/api/v1", "/api"]) { + if (url.endsWith(suffix)) return url.slice(0, -suffix.length); + } + return url; + } + + /** + * The generated OpenAPI client, rebuilt whenever its auth or headers change. + * + * The generated client captures `defaultHeaders` and `apiKey` when it is + * built, while the handwritten paths recompute `_mergedHeaders` per request. + * Rebuilding on change keeps the two halves from diverging when the inputs + * move underneath us — a caller mutating the object returned by + * `get headers`, or a profile whose auth header only becomes available after + * its token is refreshed. + */ + private get openAPIClient(): OpenAPILangsmith { + const options = this._openAPIClientOptions; + const signature = JSON.stringify([options.apiKey, options.defaultHeaders]); + if ( + this._openAPIClient === undefined || + this._openAPIClientSignature !== signature + ) { + this._openAPIClientSignature = signature; + this._openAPIClient = this._newOpenAPIClient(options); + } + return this._openAPIClient; + } + + private _newOpenAPIClient( + options = this._openAPIClientOptions, + ): OpenAPILangsmith { + const { + method: _method, + body: _body, + signal: _signal, + ...openAPIFetchOptions + } = this.fetchOptions; + + return new OpenAPILangsmith({ + apiKey: options.apiKey, + tenantID: this.workspaceId, + baseURL: this._getOpenAPIBaseUrl(), + timeout: this.timeout_ms, + fetch: this._fetch, + fetchOptions: openAPIFetchOptions, + defaultHeaders: options.defaultHeaders, + }); + } + + private _getPlatformEndpointPath(path: string): string { + // Check if apiUrl already ends with /v1 or /v1/ to avoid double /v1/v1/ paths + const needsV1Prefix = + this.apiUrl.slice(-3) !== "/v1" && this.apiUrl.slice(-4) !== "/v1/"; + return needsV1Prefix ? `/v1/platform/${path}` : `/platform/${path}`; + } + + public get evaluators(): Evaluators { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.onlineEvaluators; + } + + public get runs(): OpenAPIRuns { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.runs; + } + + /** Access the v2 sandboxes resource (registries, snapshots, boxes). */ + public get sandboxes(): Sandboxes { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.sandboxes; + } + + /** Access the v2 datasets resource (experimentRuns, etc.). */ + public get datasets(): Datasets { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.datasets; + } + + /** Access the annotation queues resource (runs, items). */ + public get annotationQueues(): AnnotationQueues { + // The items endpoints landed in backend 0.16.14; the rest are older. + this._checkStainlessVersion("0.16.14"); + return this.openAPIClient.annotationQueues; + } + + /** Access the threads resource (query, stats, listTraces). */ + public get threads(): Threads { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.threads; + } + + /** Access the traces resource (query, listRuns). */ + public get traces(): Traces { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.traces; + } + + /** Access the public shared-run resource. */ + public get public(): Public { + this._checkStainlessVersion("0.16.0"); + return this.openAPIClient.public; + } + + private async processInputs(inputs: KVMap): Promise { + if (this.hideInputs === false) { + return inputs; + } + if (this.hideInputs === true) { + return {}; + } + if (typeof this.hideInputs === "function") { + return this.hideInputs(inputs); + } + return inputs; + } + + private async processOutputs(outputs: KVMap): Promise { + if (this.hideOutputs === false) { + return outputs; + } + if (this.hideOutputs === true) { + return {}; + } + if (typeof this.hideOutputs === "function") { + return this.hideOutputs(outputs); + } + return outputs; + } + + private async processMetadata(metadata: KVMap): Promise { + if (this.hideMetadata === false) { + return metadata; + } + if (this.hideMetadata === true) { + return {}; + } + if (typeof this.hideMetadata === "function") { + return this.hideMetadata(metadata); + } + return metadata; + } + + /** + * Apply the configured anonymizer to a run's error string. + * + * Unlike inputs/outputs, `error` is a plain string (an exception message or + * traceback) that can carry credentials the user never explicitly logged -- + * e.g. an HTTP-client error whose message embeds an `Authorization` header. + * The anonymizer is typed `(KVMap) => KVMap`, so the string is wrapped as + * `{ error }`, scrubbed, and unwrapped. Mirrors the Python SDK's + * `Client._hide_run_error`. + * + * TODO: Update anonymizer to always nest inputs/outputs/error for consistency + */ + private async processError(error: string): Promise { + if (this.anonymizer == null) { + return error; + } + const result = await this.anonymizer({ error }); + return typeof result?.error === "string" ? result.error : error; + } + + /** + * Filter content from new_token events to prevent streaming LLM output + * from being uploaded via events. + */ + private _filterNewTokenEvents( + events: KVMap[] | undefined, + ): KVMap[] | undefined { + if (!events || events.length === 0) { + return events; + } + return events.map((event) => { + if (event.name === "new_token") { + // Remove the kwargs containing the token data + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { kwargs: _, ...rest } = event; + return rest; + } + return event; + }); + } + + private async prepareRunCreateOrUpdateInputs( + run: RunUpdate, + ): Promise; + private async prepareRunCreateOrUpdateInputs( + run: RunCreate, + ): Promise; + private async prepareRunCreateOrUpdateInputs( + run: RunCreate | RunUpdate, + ): Promise { + const runParams = { ...run }; + if (runParams.inputs !== undefined) { + runParams.inputs = await this.processInputs(runParams.inputs); + } + if (runParams.outputs !== undefined) { + runParams.outputs = await this.processOutputs(runParams.outputs); + } + if (runParams.error !== undefined) { + runParams.error = await this.processError(runParams.error); + } + if (runParams.extra != null && "metadata" in runParams.extra) { + runParams.extra = { + ...runParams.extra, + metadata: await this.processMetadata(runParams.extra.metadata), + }; + } + if (runParams.events !== undefined) { + runParams.events = this._filterNewTokenEvents(runParams.events); + } + return runParams; + } + + private async _getResponse( + path: string, + queryParams?: URLSearchParams, + ): Promise { + const paramsString = queryParams?.toString() ?? ""; + const url = `${this.apiUrl}${path}?${paramsString}`; + const response = await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, `fetch ${path}`); + return res; + }); + return response; + } + + private async _get( + path: string, + queryParams?: URLSearchParams, + ): Promise { + const response = await this._getResponse(path, queryParams); + return response.json() as T; + } + + private async *_getPaginated( + path: string, + queryParams: URLSearchParams = new URLSearchParams(), + transform?: (data: TResponse) => T[], + ): AsyncIterable { + let offset = Number(queryParams.get("offset")) || 0; + const limit = Number(queryParams.get("limit")) || 100; + while (true) { + queryParams.set("offset", String(offset)); + queryParams.set("limit", String(limit)); + + const url = `${this.apiUrl}${path}?${queryParams}`; + const response = await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, `fetch ${path}`); + return res; + }); + const items: T[] = transform + ? transform(await response.json()) + : await response.json(); + + if (items.length === 0) { + break; + } + yield items; + + if (items.length < limit) { + break; + } + offset += items.length; + } + } + + private async *_getCursorPaginatedList( + path: string, + body: RecordStringAny | null = null, + requestMethod = "POST", + dataKey = "runs", + ): AsyncIterable { + const bodyParams = body ? { ...body } : {}; + while (true) { + const body = JSON.stringify(bodyParams); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}${path}`, { + method: requestMethod, + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, `fetch ${path}`); + return res; + }); + const responseBody = await response.json(); + if (!responseBody) { + break; + } + if (!responseBody[dataKey]) { + break; + } + yield responseBody[dataKey]; + const cursors = responseBody.cursors; + if (!cursors) { + break; + } + if (!cursors.next) { + break; + } + bodyParams.cursor = cursors.next; + } + } + + // Allows mocking for tests + private _shouldSample(identifier?: string | null): boolean { + return isSampledById(identifier, this.tracingSampleRate); + } + + private _filterForSampling(runs: CreateRunParams[] | UpdateRunParams[]) { + // updateRun() may omit trace_id; dotted order's first segment is the root. + return runs.filter((run) => + this._shouldSample( + run.trace_id ?? + run.dotted_order?.split(".", 1)[0].split("Z")[1] ?? + run.id, + ), + ); + } + + private async _getBatchSizeLimitBytes(): Promise { + const serverInfo = await this._ensureServerInfo(); + return ( + this.batchSizeBytesLimit ?? + serverInfo?.batch_ingest_config?.size_limit_bytes ?? + DEFAULT_UNCOMPRESSED_BATCH_SIZE_LIMIT_BYTES + ); + } + + /** + * Get the maximum number of operations to batch in a single request. + */ + private async _getBatchSizeLimit(): Promise { + const serverInfo = await this._ensureServerInfo(); + return ( + this.batchSizeLimit ?? + serverInfo?.batch_ingest_config?.size_limit ?? + DEFAULT_BATCH_SIZE_LIMIT + ); + } + + private async _getDatasetExamplesMultiPartSupport(): Promise { + const serverInfo = await this._ensureServerInfo(); + return ( + serverInfo.instance_flags?.dataset_examples_multipart_enabled ?? false + ); + } + + private drainAutoBatchQueue({ + batchSizeLimitBytes, + batchSizeLimit, + }: { + batchSizeLimitBytes: number; + batchSizeLimit: number; + }) { + const promises = []; + while (this.autoBatchQueue.items.length > 0) { + const [batch, done] = this.autoBatchQueue.pop({ + upToSizeBytes: batchSizeLimitBytes, + upToSize: batchSizeLimit, + }); + if (!batch.length) { + done(); + break; + } + const batchesByDestination = batch.reduce( + (acc, item) => { + const apiUrl = item.apiUrl ?? this.apiUrl; + const apiKey = item.apiKey ?? this.apiKey; + const workspaceId = item.workspaceId ?? this.workspaceId; + const isDefault = + item.apiKey === this.apiKey && + item.apiUrl === this.apiUrl && + item.workspaceId === this.workspaceId; + const batchKey = isDefault + ? "default" + : `${apiUrl}|${apiKey}|${workspaceId ?? ""}`; + if (!acc[batchKey]) { + acc[batchKey] = []; + } + acc[batchKey].push(item); + return acc; + }, + {} as Record, + ); + + const batchPromises = []; + for (const [batchKey, batch] of Object.entries(batchesByDestination)) { + const isDefault = batchKey === "default"; + const parts = isDefault ? [] : batchKey.split("|"); + const workspaceIdPart = parts[2]; + const batchPromise = this._processBatch(batch, { + apiUrl: isDefault ? undefined : parts[0], + apiKey: isDefault ? undefined : parts[1], + workspaceId: + isDefault || !workspaceIdPart ? undefined : workspaceIdPart, + }); + batchPromises.push(batchPromise); + } + + // Wait for all batches to complete, then call the overall done callback + const allBatchesPromise = Promise.all(batchPromises).finally(done); + promises.push(allBatchesPromise); + } + return Promise.all(promises); + } + + /** + * Persist a failed trace payload to a local fallback directory. + * + * Saves a self-contained JSON file containing the endpoint path, the HTTP + * headers required for replay, and the base64-encoded request body. + * Can be replayed later with a simple POST: + * + * POST / + * Content-Type: + * [Content-Encoding: ] + * + */ + private static async _writeTraceToFallbackDir( + directory: string, + body: ArrayBuffer | string, + replayHeaders: Record, + endpoint: string, + maxBytes?: number, + ): Promise { + try { + const bodyBuffer = + typeof body === "string" + ? Buffer.from(body, "utf8") + : Buffer.from(body); + const envelope = JSON.stringify({ + version: 1, + endpoint, + headers: replayHeaders, + body_base64: bodyBuffer.toString("base64"), + }); + const filename = `trace_${Date.now()}_${uuid.v4().slice(0, 8)}.json`; + const filepath = fsUtils.path.join(directory, filename); + if (!Client._fallbackDirsCreated.has(directory)) { + await fsUtils.mkdir(directory); + Client._fallbackDirsCreated.add(directory); + } + // Check budget before writing — drop new traces if over limit. + if (maxBytes !== undefined && maxBytes > 0) { + try { + const entries = await fsUtils.readdir(directory); + const traceFiles = entries.filter( + (f) => f.startsWith("trace_") && f.endsWith(".json"), + ); + let total = 0; + for (const name of traceFiles) { + const { size } = await fsUtils.stat( + fsUtils.path.join(directory, name), + ); + total += size; + } + if (total >= maxBytes) { + console.warn( + `Could not write trace to fallback dir ${directory} as it's ` + + `already over size limit (${total} bytes >= ${maxBytes} bytes). ` + + `Increase LANGSMITH_FAILED_TRACES_MAX_MB if possible.`, + ); + return; + } + } catch { + // budget check errors must never prevent writing + } + } + await fsUtils.writeFileAtomic(filepath, envelope); + console.warn( + `LangSmith trace upload failed; data saved to ${filepath} for later replay.`, + ); + } catch (writeErr) { + console.error( + `LangSmith tracing error: could not write trace to fallback dir ${directory}:`, + writeErr, + ); + } + } + + private async _processBatch( + batch: AutoBatchQueueItem[], + options?: { apiKey?: string; apiUrl?: string; workspaceId?: string }, + ) { + if (!batch.length) { + return; + } + // Calculate total batch size for queue tracking + const batchSizeBytes = batch.reduce( + (sum, item) => sum + (item.size ?? 0), + 0, + ); + + try { + if (this.langSmithToOTELTranslator !== undefined) { + for (const item of batch) { + item.item = await this._maskRunMetadata(item.item); + } + this._sendBatchToOTELTranslator(batch); + } else { + const ingestParams = { + runCreates: batch + .filter((item) => item.action === "create") + .map((item) => item.item) as RunCreate[], + runUpdates: batch + .filter((item) => item.action === "update") + .map((item) => item.item) as RunUpdate[], + }; + const serverInfo = await this._ensureServerInfo(); + const useMultipart = + !this._multipartDisabled && + (serverInfo?.batch_ingest_config?.use_multipart_endpoint ?? true); + if (useMultipart) { + const useGzip = + !this._runCompressionDisabled && + serverInfo?.instance_flags?.gzip_body_enabled; + try { + await this.multipartIngestRuns(ingestParams, { + ...options, + useGzip, + sizeBytes: batchSizeBytes, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if (isLangSmithNotFoundError(e)) { + // Fallback to batch ingest if multipart endpoint returns 404 + // Disable multipart for future requests + this._multipartDisabled = true; + await this.batchIngestRuns(ingestParams, { + ...options, + sizeBytes: batchSizeBytes, + }); + } else { + throw e; + } + } + } else { + await this.batchIngestRuns(ingestParams, { + ...options, + sizeBytes: batchSizeBytes, + }); + } + } + } catch (e) { + console.error("Error exporting batch:", e); + } + } + + private _sendBatchToOTELTranslator(batch: AutoBatchQueueItem[]) { + if (this.langSmithToOTELTranslator !== undefined) { + const otelContextMap = new Map(); + const operations: SerializedRunOperation[] = []; + for (const item of batch) { + if (item.item.id && item.otelContext) { + otelContextMap.set(item.item.id, item.otelContext); + if (item.action === "create") { + operations.push({ + operation: "post", + id: item.item.id, + trace_id: item.item.trace_id ?? item.item.id, + run: item.item as RunCreate, + }); + } else { + operations.push({ + operation: "patch", + id: item.item.id, + trace_id: item.item.trace_id ?? item.item.id, + run: item.item as RunUpdate, + }); + } + } + } + this.langSmithToOTELTranslator.exportBatch(operations, otelContextMap); + } + } + + private async _maskRunMetadata( + run: T, + ): Promise { + if (run.extra?.metadata == null) { + return run; + } + return { + ...run, + extra: { + ...run.extra, + metadata: await this.processMetadata(run.extra.metadata), + }, + }; + } + + private async _mergeRuntimeEnvAndMaskMetadata< + T extends RunCreate | RunUpdate, + >(run: T): Promise { + const merged = mergeRuntimeEnvIntoRun( + run, + this.cachedLSEnvVarsForMetadata, + this.omitTracedRuntimeInfo, + this.tracingSampleRate, + ); + if (this.omitTracedRuntimeInfo) { + return merged; + } + return this._maskRunMetadata(merged); + } + + private async processRunOperation(item: AutoBatchQueueItem) { + clearTimeout(this.autoBatchTimeout); + this.autoBatchTimeout = undefined; + item.item = mergeRuntimeEnvIntoRun( + item.item as RunCreate, + this.cachedLSEnvVarsForMetadata, + this.omitTracedRuntimeInfo, + this.tracingSampleRate, + ); + const itemPromise = this.autoBatchQueue.push(item); + if (this.manualFlushMode) { + // Rely on manual flushing in serverless environments + return itemPromise; + } + const sizeLimitBytes = await this._getBatchSizeLimitBytes(); + const sizeLimit = await this._getBatchSizeLimit(); + if ( + this.autoBatchQueue.sizeBytes > sizeLimitBytes || + this.autoBatchQueue.items.length > sizeLimit + ) { + this._trackDrain( + this.drainAutoBatchQueue({ + batchSizeLimitBytes: sizeLimitBytes, + batchSizeLimit: sizeLimit, + }), + ); + } + if (this.autoBatchQueue.items.length > 0) { + this.autoBatchTimeout = setTimeout(() => { + this.autoBatchTimeout = undefined; + this._trackDrain( + this.drainAutoBatchQueue({ + batchSizeLimitBytes: sizeLimitBytes, + batchSizeLimit: sizeLimit, + }), + ); + }, this.autoBatchAggregationDelayMs); + } + return itemPromise; + } + + protected async _getServerInfo() { + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/info`, { + method: "GET", + headers: { ...this._mergedHeaders, Accept: "application/json" }, + signal: AbortSignal.timeout(SERVER_INFO_REQUEST_TIMEOUT_MS), + ...this.fetchOptions, + }); + await raiseForStatus(res, "get server info"); + return res; + }); + const json = await response.json(); + if (this.debug) { + console.log( + "\n=== LangSmith Server Configuration ===\n" + + JSON.stringify(json, null, 2) + + "\n", + ); + } + return json; + } + + private _checkStainlessVersion(minVersion: string): void { + if (this._stainlessVersionsChecked.has(minVersion)) return; + this._stainlessVersionsChecked.add(minVersion); + this._ensureServerInfo() + .then((serverInfo) => { + _checkBackendVersion(serverInfo?.version, minVersion); + }) + .catch(() => { + // _ensureServerInfo handles and logs its own errors + }); + } + + protected async _ensureServerInfo() { + if (this._getServerInfoPromise === undefined) { + this._getServerInfoPromise = (async () => { + if (this._serverInfo === undefined) { + try { + this._serverInfo = await this._getServerInfo(); + } catch (e: any) { + console.warn( + `[LANGSMITH]: Failed to fetch info on supported operations. Falling back to batch operations and default limits. Info: ${ + e.status ?? "Unspecified status code" + } ${e.message}`, + ); + } + } + return this._serverInfo ?? {}; + })(); + } + return this._getServerInfoPromise.then((serverInfo) => { + if (this._serverInfo === undefined) { + this._getServerInfoPromise = undefined; + } + return serverInfo; + }); + } + + public async _supportsSDBQuery(): Promise { + const serverInfo = await this._ensureServerInfo(); + return serverInfo.instance_flags?.sdb_query_enabled === true; + } + + /** + * Throw on SmithDB-only deployments, warn elsewhere. Call only when run-level + * feedback has no sessionId. + */ + private async _checkFeedbackSessionId(): Promise { + const docs = + "https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#feedback-create"; + const serverInfo = await this._ensureServerInfo(); + if ( + getQueryBackend(serverInfo.instance_flags) === QueryBackend.SMITHDB_ONLY + ) { + throw new Error( + `sessionId must be provided when creating feedback for a run: this ` + + `deployment cannot locate the run without it. See ${docs}`, + ); + } + warnOnce( + `Creating feedback for a run without sessionId is deprecated and will ` + + `stop working in a future release. See ${docs}`, + ); + } + + protected async _getSettings() { + if (!this.settings) { + this.settings = this._get("/settings"); + } + + return await this.settings; + } + + /** + * Flushes current queued traces. + */ + public async flush() { + const sizeLimitBytes = await this._getBatchSizeLimitBytes(); + const sizeLimit = await this._getBatchSizeLimit(); + await this.drainAutoBatchQueue({ + batchSizeLimitBytes: sizeLimitBytes, + batchSizeLimit: sizeLimit, + }); + } + + private _cloneCurrentOTELContext() { + const otel_trace = getOTELTrace(); + const otel_context = getOTELContext(); + if (this.langSmithToOTELTranslator !== undefined) { + const currentSpan = otel_trace.getActiveSpan(); + if (currentSpan) { + return otel_trace.setSpan(otel_context.active(), currentSpan); + } + } + return undefined; + } + + public async createRun( + run: CreateRunParams, + options?: { apiKey?: string; apiUrl?: string; workspaceId?: string }, + ): Promise { + if (!this._filterForSampling([run]).length) { + return; + } + const headers: Record = { + ...this._mergedHeaders, + "Content-Type": "application/json", + }; + const session_name = run.project_name; + delete run.project_name; + + const runCreate: RunCreate = await this.prepareRunCreateOrUpdateInputs({ + session_name, + ...run, + start_time: run.start_time ?? Date.now(), + } as RunCreate); + if ( + this.autoBatchTracing && + runCreate.trace_id !== undefined && + runCreate.dotted_order !== undefined + ) { + const otelContext = this._cloneCurrentOTELContext(); + void this.processRunOperation({ + action: "create", + item: runCreate, + otelContext, + apiKey: options?.apiKey, + apiUrl: options?.apiUrl, + workspaceId: options?.workspaceId, + }).catch(console.error); + return; + } + const mergedRunCreateParam = + await this._mergeRuntimeEnvAndMaskMetadata(runCreate); + if (options?.apiKey !== undefined) { + headers["x-api-key"] = options.apiKey; + } + if (options?.workspaceId !== undefined) { + headers["x-tenant-id"] = options.workspaceId; + } + const body = serializePayloadForTracing( + mergedRunCreateParam, + `Creating run with id: ${mergedRunCreateParam.id}`, + ); + await this.caller.call(async () => { + const res = await this._fetch(`${options?.apiUrl ?? this.apiUrl}/runs`, { + method: "POST", + headers, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "create run", true); + return res; + }); + } + + /** + * Batch ingest/upsert multiple runs in the Langsmith system. + * @param runs + */ + public async batchIngestRuns( + { + runCreates, + runUpdates, + }: { + runCreates?: RunCreate[]; + runUpdates?: RunUpdate[]; + }, + options?: { + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + sizeBytes?: number; + }, + ) { + if (runCreates === undefined && runUpdates === undefined) { + return; + } + let preparedCreateParams = await Promise.all( + runCreates?.map((create) => + this.prepareRunCreateOrUpdateInputs(create), + ) ?? [], + ); + let preparedUpdateParams = await Promise.all( + runUpdates?.map((update) => + this.prepareRunCreateOrUpdateInputs(update), + ) ?? [], + ); + + // Index even without updates: the collision warning must see creates-only batches. + const createById = indexRunCreatesById(preparedCreateParams); + if (preparedCreateParams.length > 0 && preparedUpdateParams.length > 0) { + const standaloneUpdates = []; + for (const updateParam of preparedUpdateParams) { + if (updateParam.id !== undefined && createById[updateParam.id]) { + createById[updateParam.id] = { + ...createById[updateParam.id], + ...updateParam, + }; + } else { + standaloneUpdates.push(updateParam); + } + } + preparedCreateParams = Object.values(createById); + preparedUpdateParams = standaloneUpdates; + } + const rawBatch = { + post: preparedCreateParams, + patch: preparedUpdateParams, + }; + if (!rawBatch.post.length && !rawBatch.patch.length) { + return; + } + const batchChunks = { + post: [] as (typeof rawBatch)["post"], + patch: [] as (typeof rawBatch)["patch"], + }; + for (const k of ["post", "patch"]) { + const key = k as keyof typeof rawBatch; + const batchItems = rawBatch[key].reverse(); + let batchItem = batchItems.pop(); + while (batchItem !== undefined) { + // Type is wrong but this is a deprecated code path anyway + batchChunks[key].push(batchItem as any); + batchItem = batchItems.pop(); + } + } + if (batchChunks.post.length > 0 || batchChunks.patch.length > 0) { + const runIds = batchChunks.post + .map((item) => item.id) + .concat(batchChunks.patch.map((item) => item.id)) + .join(","); + await this._postBatchIngestRuns( + await this._serializeBody( + batchChunks, + `Ingesting runs with ids: ${runIds}`, + ), + options, + ); + } + } + + private async _postBatchIngestRuns( + body: Uint8Array, + options?: { + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + sizeBytes?: number; + }, + ) { + const headers: Record = { + ...this._mergedHeaders, + "Content-Type": "application/json", + Accept: "application/json", + }; + if (options?.apiKey !== undefined) { + headers["x-api-key"] = options.apiKey; + } + if (options?.workspaceId !== undefined) { + headers["x-tenant-id"] = options.workspaceId; + } + await this.batchIngestCaller.callWithOptions( + { sizeBytes: options?.sizeBytes }, + async () => { + const res = await this._fetch( + `${options?.apiUrl ?? this.apiUrl}/runs/batch`, + { + method: "POST", + headers, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "batch create run", true); + return res; + }, + ); + } + + /** + * Batch ingest/upsert multiple runs in the Langsmith system. + * @param runs + */ + public async multipartIngestRuns( + { + runCreates, + runUpdates, + }: { + runCreates?: RunCreate[]; + runUpdates?: RunUpdate[]; + }, + options?: { + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + useGzip?: boolean; + sizeBytes?: number; + }, + ) { + if (runCreates === undefined && runUpdates === undefined) { + return; + } + // transform and convert to dicts + const allAttachments: Record = {}; + let preparedCreateParams = []; + + for (const create of runCreates ?? []) { + const preparedCreate = await this.prepareRunCreateOrUpdateInputs(create); + if ( + preparedCreate.id !== undefined && + preparedCreate.attachments !== undefined + ) { + allAttachments[preparedCreate.id] = preparedCreate.attachments; + } + delete preparedCreate.attachments; + preparedCreateParams.push(preparedCreate); + } + let preparedUpdateParams = []; + for (const update of runUpdates ?? []) { + preparedUpdateParams.push( + await this.prepareRunCreateOrUpdateInputs(update), + ); + } + + // require trace_id and dotted_order + const invalidRunCreate = preparedCreateParams.find((runCreate) => { + return ( + runCreate.trace_id === undefined || runCreate.dotted_order === undefined + ); + }); + if (invalidRunCreate !== undefined) { + throw new Error( + `Multipart ingest requires "trace_id" and "dotted_order" to be set when creating a run`, + ); + } + const invalidRunUpdate = preparedUpdateParams.find((runUpdate) => { + return ( + runUpdate.trace_id === undefined || runUpdate.dotted_order === undefined + ); + }); + if (invalidRunUpdate !== undefined) { + throw new Error( + `Multipart ingest requires "trace_id" and "dotted_order" to be set when updating a run`, + ); + } + // combine post and patch dicts where possible + const createById = indexRunCreatesById(preparedCreateParams); + if (preparedCreateParams.length > 0 && preparedUpdateParams.length > 0) { + const standaloneUpdates = []; + for (const updateParam of preparedUpdateParams) { + if (updateParam.id !== undefined && createById[updateParam.id]) { + createById[updateParam.id] = { + ...createById[updateParam.id], + ...updateParam, + }; + } else { + standaloneUpdates.push(updateParam); + } + } + preparedCreateParams = Object.values(createById); + preparedUpdateParams = standaloneUpdates; + } + if ( + preparedCreateParams.length === 0 && + preparedUpdateParams.length === 0 + ) { + return; + } + // send the runs in multipart requests + const accumulatedContext: string[] = []; + const accumulatedParts: MultipartPart[] = []; + for (const [method, payloads] of [ + ["post", preparedCreateParams] as const, + ["patch", preparedUpdateParams] as const, + ]) { + for (const originalPayload of payloads) { + // collect fields to be sent as separate parts + const { + inputs, + outputs, + events, + extra, + error, + serialized, + attachments, + ...payload + } = originalPayload; + const fields = { inputs, outputs, events, extra, error, serialized }; + // encode the main run payload + const stringifiedPayload = await this._serializeBody( + payload, + `Serializing for multipart ingestion of run with id: ${payload.id}`, + ); + accumulatedParts.push({ + name: `${method}.${payload.id}`, + payload: new Blob([stringifiedPayload], { + type: `application/json; length=${stringifiedPayload.length}`, // encoding=gzip + }), + }); + // encode the fields we collected + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) { + continue; + } + const stringifiedValue = await this._serializeBody( + value, + `Serializing ${key} for multipart ingestion of run with id: ${payload.id}`, + ); + accumulatedParts.push({ + name: `${method}.${payload.id}.${key}`, + payload: new Blob([stringifiedValue], { + type: `application/json; length=${stringifiedValue.length}`, + }), + }); + } + // encode the attachments + if (payload.id !== undefined) { + const attachments = allAttachments[payload.id]; + if (attachments) { + delete allAttachments[payload.id]; + for (const [name, attachment] of Object.entries(attachments)) { + let contentType: string; + let content: AttachmentData; + + if (Array.isArray(attachment)) { + [contentType, content] = attachment; + } else { + contentType = attachment.mimeType; + content = attachment.data; + } + + // Validate that the attachment name doesn't contain a '.' + if (name.includes(".")) { + console.warn( + `Skipping attachment '${name}' for run ${payload.id}: Invalid attachment name. ` + + `Attachment names must not contain periods ('.'). Please rename the attachment and try again.`, + ); + continue; + } + accumulatedParts.push({ + name: `attachment.${payload.id}.${name}`, + payload: new Blob([content as BlobPart], { + type: `${contentType}; length=${content.byteLength}`, + }), + }); + } + } + } + // compute context + accumulatedContext.push(`trace=${payload.trace_id},id=${payload.id}`); + } + } + await this._sendMultipartRequest( + accumulatedParts, + accumulatedContext.join("; "), + options, + ); + } + + private async _createNodeFetchBody(parts: MultipartPart[], boundary: string) { + // Create multipart form data manually using Blobs + const chunks: Blob[] = []; + + for (const part of parts) { + // Add field boundary + chunks.push(new Blob([`--${boundary}\r\n`])); + chunks.push( + new Blob([ + `Content-Disposition: form-data; name="${part.name}"\r\n`, + `Content-Type: ${part.payload.type}\r\n\r\n`, + ]), + ); + chunks.push(part.payload); + chunks.push(new Blob(["\r\n"])); + } + + // Add final boundary + chunks.push(new Blob([`--${boundary}--\r\n`])); + + // Combine all chunks into a single Blob + const body = new Blob(chunks); + + // Convert Blob to ArrayBuffer for compatibility + const arrayBuffer = await body.arrayBuffer(); + return arrayBuffer; + } + + private async _createMultipartStream( + parts: MultipartPart[], + boundary: string, + ) { + const encoder = new TextEncoder(); + // Create a ReadableStream for streaming the multipart data + // Only do special handling if we're using node-fetch + const stream = new ReadableStream({ + async start(controller) { + // Helper function to write a chunk to the stream + const writeChunk = async (chunk: string | Blob) => { + if (typeof chunk === "string") { + controller.enqueue(encoder.encode(chunk)); + } else { + controller.enqueue(chunk); + } + }; + + // Write each part to the stream + for (const part of parts) { + // Write boundary and headers + await writeChunk(`--${boundary}\r\n`); + await writeChunk( + `Content-Disposition: form-data; name="${part.name}"\r\n`, + ); + await writeChunk(`Content-Type: ${part.payload.type}\r\n\r\n`); + + // Write the payload + const payloadStream = part.payload.stream(); + const reader = payloadStream.getReader(); + + try { + let result; + while (!(result = await reader.read()).done) { + controller.enqueue(result.value); + } + } finally { + reader.releaseLock(); + } + + await writeChunk("\r\n"); + } + + // Write final boundary + await writeChunk(`--${boundary}--\r\n`); + controller.close(); + }, + }); + return stream; + } + + private async _sendMultipartRequest( + parts: MultipartPart[], + context: string, + options?: { + apiKey?: string; + apiUrl?: string; + workspaceId?: string; + useGzip?: boolean; + sizeBytes?: number; + }, + ) { + // Create multipart form data boundary + const boundary = + "----LangSmithFormBoundary" + Math.random().toString(36).slice(2); + + const buildBuffered = () => this._createNodeFetchBody(parts, boundary); + const buildStream = () => this._createMultipartStream(parts, boundary); + + const sendWithRetry = async ( + bodyFactory: () => Promise, + ): Promise => { + return this.batchIngestCaller.callWithOptions( + { sizeBytes: options?.sizeBytes }, + async () => { + const body = await bodyFactory(); + const headers: Record = { + ...this._mergedHeaders, + "Content-Type": `multipart/form-data; boundary=${boundary}`, + }; + if (options?.apiKey !== undefined) { + headers["x-api-key"] = options.apiKey; + } + if (options?.workspaceId !== undefined) { + headers["x-tenant-id"] = options.workspaceId; + } + + let transformedBody = body; + if ( + options?.useGzip && + typeof body === "object" && + "pipeThrough" in body + ) { + transformedBody = body.pipeThrough(new CompressionStream("gzip")); + headers["Content-Encoding"] = "gzip"; + } + + const response = await this._fetch( + `${options?.apiUrl ?? this.apiUrl}/runs/multipart`, + { + method: "POST", + headers, + body: transformedBody, + duplex: "half", + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + } as RequestInit, + ); + + await raiseForStatus( + response, + `Failed to send multipart request`, + true, + ); + + return response; + }, + ); + }; + + try { + let res: Response; + let streamedAttempt = false; + + const shouldStream = _shouldStreamForGlobalFetchImplementation(); + + // attempt stream only if not disabled and not using node-fetch or Bun; + if ( + shouldStream && + !this.multipartStreamingDisabled && + getEnv() !== "bun" + ) { + streamedAttempt = true; + res = await sendWithRetry(buildStream); + } else { + res = await sendWithRetry(buildBuffered); + } + + // if stream fails, fallback to buffered body + if ( + (!this.multipartStreamingDisabled || streamedAttempt) && + res.status === 422 && + (options?.apiUrl ?? this.apiUrl) !== DEFAULT_API_URL + ) { + console.warn( + `Streaming multipart upload to ${ + options?.apiUrl ?? this.apiUrl + }/runs/multipart failed. ` + + `This usually means the host does not support chunked uploads. ` + + `Retrying with a buffered upload for operation "${context}".`, + ); + // Disable streaming for future requests + this.multipartStreamingDisabled = true; + // retry with fully-buffered body + res = await sendWithRetry(buildBuffered); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + // Re-throw 404 errors so caller can fall back to batch ingest + if (isLangSmithNotFoundError(e)) { + throw e; + } + console.warn(`${e.message.trim()}\n\nContext: ${context}`); + if (this.failedTracesDir) { + const bodyBuffer = await this._createNodeFetchBody( + parts, + boundary, + ).catch(() => null); + if (bodyBuffer) { + await Client._writeTraceToFallbackDir( + this.failedTracesDir, + bodyBuffer, + { "Content-Type": `multipart/form-data; boundary=${boundary}` }, + "runs/multipart", + this.failedTracesMaxBytes, + ); + } + } + } + } + + public async updateRun( + runId: string, + run: RunUpdate, + options?: { apiKey?: string; apiUrl?: string; workspaceId?: string }, + ): Promise { + assertUuid(runId); + if (run.inputs) { + run.inputs = await this.processInputs(run.inputs); + } + + if (run.outputs) { + run.outputs = await this.processOutputs(run.outputs); + } + if (run.error) { + run.error = await this.processError(run.error); + } + if (run.extra != null && "metadata" in run.extra) { + run.extra = { + ...run.extra, + metadata: await this.processMetadata(run.extra.metadata), + }; + } + + if (run.events) { + run.events = this._filterNewTokenEvents(run.events); + } + // TODO: Untangle types + const data: UpdateRunParams = { ...run, id: runId }; + if (!this._filterForSampling([data]).length) { + return; + } + if ( + this.autoBatchTracing && + data.trace_id !== undefined && + data.dotted_order !== undefined + ) { + const otelContext = this._cloneCurrentOTELContext(); + if ( + run.end_time !== undefined && + data.parent_run_id === undefined && + this.blockOnRootRunFinalization && + !this.manualFlushMode + ) { + // Trigger batches as soon as a root trace ends and wait to ensure trace finishes + // in serverless environments. + await this.processRunOperation({ + action: "update", + item: data, + otelContext, + apiKey: options?.apiKey, + apiUrl: options?.apiUrl, + workspaceId: options?.workspaceId, + }).catch(console.error); + return; + } else { + void this.processRunOperation({ + action: "update", + item: data, + otelContext, + apiKey: options?.apiKey, + apiUrl: options?.apiUrl, + workspaceId: options?.workspaceId, + }).catch(console.error); + } + return; + } + const headers: Record = { + ...this._mergedHeaders, + "Content-Type": "application/json", + }; + if (options?.apiKey !== undefined) { + headers["x-api-key"] = options.apiKey; + } + if (options?.workspaceId !== undefined) { + headers["x-tenant-id"] = options.workspaceId; + } + const body = serializePayloadForTracing( + run.extra + ? mergeRuntimeEnvIntoRun( + run, + this.cachedLSEnvVarsForMetadata, + this.omitTracedRuntimeInfo, + this.tracingSampleRate, + ) + : run, + `Serializing payload to update run with id: ${runId}`, + ); + await this.caller.call(async () => { + const res = await this._fetch( + `${options?.apiUrl ?? this.apiUrl}/runs/${runId}`, + { + method: "PATCH", + headers, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "update run", true); + return res; + }); + } + + /** @deprecated Use `client.runs.retrieve()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-runs#runs-retrieve for the migration guide. Will be removed after Jan 31, 2027. */ + public async readRun( + runId: string, + { loadChildRuns }: { loadChildRuns: boolean } = { loadChildRuns: false }, + ): Promise { + warnOnce( + "readRun() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.runs.retrieve() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-runs#runs-retrieve for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_READ_RUN" }, + ); + return this._readRun(runId, { loadChildRuns }); + } + + /** + * Fetch a run without emitting the `readRun()` deprecation warning. + * + * Internal callers use this so that a supported method doesn't warn about a + * deprecated one the caller never invoked. + * + * @internal + */ + public async _readRun( + runId: string, + { loadChildRuns }: { loadChildRuns: boolean } = { loadChildRuns: false }, + ): Promise { + assertUuid(runId); + let run = _normalizeRunTimestamps(await this._get(`/runs/${runId}`)); + if (loadChildRuns) { + run = await this._loadChildRuns(run); + } + return run; + } + + /** @deprecated Use `client.runs.getURL()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-runs#runs-get-url for the migration guide. Will be removed after Jan 31, 2027. */ + public async getRunUrl({ + runId, + run, + projectOpts, + }: { + runId?: string; + run?: Run; + projectOpts?: ProjectOptions; + }): Promise { + warnOnce( + "getRunUrl() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.runs.getURL() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-runs#runs-get-url for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_GET_RUN_URL" }, + ); + if (run !== undefined) { + let sessionId: string; + if (run.session_id) { + sessionId = run.session_id; + } else if (projectOpts?.projectName) { + sessionId = ( + await this.readProject({ projectName: projectOpts?.projectName }) + ).id; + } else if (projectOpts?.projectId) { + sessionId = projectOpts?.projectId; + } else { + const project = await this.readProject({ + projectName: getLangSmithEnvironmentVariable("PROJECT") || "default", + }); + sessionId = project.id; + } + const tenantId = await this._getTenantId(); + return `${this.getHostUrl()}/o/${tenantId}/projects/p/${sessionId}/r/${ + run.id + }?poll=true`; + } else if (runId !== undefined) { + // Via _readRun() rather than readRun() so callers don't also get a + // readRun deprecation warning for a method they never called. + const run_ = await this._readRun(runId); + if (!run_.app_path) { + throw new Error(`Run ${runId} has no app_path`); + } + const baseUrl = this.getHostUrl(); + return `${baseUrl}${run_.app_path}`; + } else { + throw new Error("Must provide either runId or run"); + } + } + + private async _loadChildRuns(run: Run): Promise { + const childRuns = await toArray( + this._listRuns({ + isRoot: false, + projectId: run.session_id, + traceId: run.trace_id, + }), + ); + const treemap: { [key: string]: Run[] } = {}; + const runs: { [key: string]: Run } = {}; + // TODO: make dotted order required when the migration finishes + childRuns.sort((a, b) => + (a?.dotted_order ?? "").localeCompare(b?.dotted_order ?? ""), + ); + for (const childRun of childRuns) { + if ( + childRun.parent_run_id === null || + childRun.parent_run_id === undefined + ) { + throw new Error(`Child run ${childRun.id} has no parent`); + } + if ( + childRun.dotted_order?.startsWith(run.dotted_order ?? "") && + childRun.id !== run.id + ) { + if (!(childRun.parent_run_id in treemap)) { + treemap[childRun.parent_run_id] = []; + } + treemap[childRun.parent_run_id].push(childRun); + runs[childRun.id] = childRun; + } + } + run.child_runs = treemap[run.id] || []; + for (const runId in treemap) { + if (runId !== run.id) { + runs[runId].child_runs = treemap[runId]; + } + } + return run; + } + + /** + * List runs from the LangSmith server. + * @deprecated Use `client.runs.query()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-query-runs#runs-query for the migration guide. Will be removed after Jan 31, 2027. + * @param projectId - The ID of the project to filter by. + * @param projectName - The name of the project to filter by. + * @param parentRunId - The ID of the parent run to filter by. + * @param traceId - The ID of the trace to filter by. + * @param referenceExampleId - The ID of the reference example to filter by. + * @param startTime - The start time to filter by. + * @param isRoot - Indicates whether to only return root runs. + * @param runType - The run type to filter by. + * @param error - Indicates whether to filter by error runs. + * @param id - The ID of the run to filter by. + * @param query - The query string to filter by. + * @param filter - The filter string to apply to the run spans. + * @param traceFilter - The filter string to apply on the root run of the trace. + * @param treeFilter - The filter string to apply on other runs in the trace. + * @param limit - The maximum number of runs to retrieve. + * @returns {AsyncIterable} - The runs. + * + * @example + * // List all runs in a project + * const projectRuns = client.listRuns({ projectName: "" }); + * + * @example + * // List LLM and Chat runs in the last 24 hours + * const todaysLLMRuns = client.listRuns({ + * projectName: "", + * start_time: new Date(Date.now() - 24 * 60 * 60 * 1000), + * run_type: "llm", + * }); + * + * @example + * // List traces in a project + * const rootRuns = client.listRuns({ + * projectName: "", + * execution_order: 1, + * }); + * + * @example + * // List runs without errors + * const correctRuns = client.listRuns({ + * projectName: "", + * error: false, + * }); + * + * @example + * // List runs by run ID + * const runIds = [ + * "a36092d2-4ad5-4fb4-9c0d-0dba9a2ed836", + * "9398e6be-964f-4aa4-8ae9-ad78cd4b7074", + * ]; + * const selectedRuns = client.listRuns({ run_ids: runIds }); + * + * @example + * // List all "chain" type runs that took more than 10 seconds and had `total_tokens` greater than 5000 + * const chainRuns = client.listRuns({ + * projectName: "", + * filter: 'and(eq(run_type, "chain"), gt(latency, 10), gt(total_tokens, 5000))', + * }); + * + * @example + * // List all runs called "extractor" whose root of the trace was assigned feedback "user_score" score of 1 + * const goodExtractorRuns = client.listRuns({ + * projectName: "", + * filter: 'eq(name, "extractor")', + * traceFilter: 'and(eq(feedback_key, "user_score"), eq(feedback_score, 1))', + * }); + * + * @example + * // List all runs that started after a specific timestamp and either have "error" not equal to null or a "Correctness" feedback score equal to 0 + * const complexRuns = client.listRuns({ + * projectName: "", + * filter: 'and(gt(start_time, "2023-07-15T12:34:56Z"), or(neq(error, null), and(eq(feedback_key, "Correctness"), eq(feedback_score, 0.0))))', + * }); + * + * @example + * // List all runs where `tags` include "experimental" or "beta" and `latency` is greater than 2 seconds + * const taggedRuns = client.listRuns({ + * projectName: "", + * filter: 'and(or(has(tags, "experimental"), has(tags, "beta")), gt(latency, 2))', + * }); + */ + public async *listRuns(props: ListRunsParams): AsyncIterable { + warnOnce( + "listRuns() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.runs.query() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-query-runs#runs-query for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_LIST_RUNS" }, + ); + yield* this._listRuns(props); + } + + /** + * List runs without emitting the `listRuns()` deprecation warning. + * + * Internal callers use this so that a supported method doesn't warn about a + * deprecated one the caller never invoked. + * + * @internal + */ + public async *_listRuns(props: ListRunsParams): AsyncIterable { + const { + projectId, + projectName, + parentRunId, + traceId, + referenceExampleId, + startTime, + executionOrder, + isRoot, + runType, + error, + id, + query, + filter, + traceFilter, + treeFilter, + limit, + select, + order, + } = props; + let projectIds: string[] = []; + if (projectId) { + projectIds = Array.isArray(projectId) ? projectId : [projectId]; + } + if (projectName) { + const projectNames = Array.isArray(projectName) + ? projectName + : [projectName]; + const projectIds_ = await Promise.all( + projectNames.map((name) => + this.readProject({ projectName: name }).then((project) => project.id), + ), + ); + projectIds.push(...projectIds_); + } + const default_select = [ + "app_path", + "completion_cost", + "completion_tokens", + "dotted_order", + "end_time", + "error", + "events", + "extra", + "feedback_stats", + "first_token_time", + "id", + "inputs", + "name", + "outputs", + "parent_run_id", + "parent_run_ids", + "prompt_cost", + "prompt_tokens", + "reference_example_id", + "run_type", + "session_id", + "start_time", + "status", + "tags", + "total_cost", + "total_tokens", + "trace_id", + ]; + const body = { + session: projectIds.length ? projectIds : null, + run_type: runType, + reference_example: referenceExampleId, + query, + filter, + trace_filter: traceFilter, + tree_filter: treeFilter, + execution_order: executionOrder, + parent_run: parentRunId, + start_time: startTime ? startTime.toISOString() : null, + error, + id, + limit, + trace: traceId, + select: select ? select : default_select, + is_root: isRoot, + order, + }; + + if (body.select.includes("child_run_ids")) { + warnOnce( + "Deprecated: 'child_run_ids' in the listRuns select parameter is deprecated and will be removed in a future version.", + ); + } + + let runsYielded = 0; + for await (const runs of this._getCursorPaginatedList( + "/runs/query", + body, + )) { + const normalized = runs.map(_normalizeRunTimestamps); + if (limit) { + if (runsYielded >= limit) { + break; + } + if (normalized.length + runsYielded > limit) { + const newRuns = normalized.slice(0, limit - runsYielded); + yield* newRuns; + break; + } + runsYielded += normalized.length; + yield* normalized; + } else { + yield* normalized; + } + } + } + + public async *listGroupRuns(props: GroupRunsParams): AsyncIterable { + const { + projectId, + projectName, + groupBy, + filter, + startTime, + endTime, + limit, + offset, + } = props; + + const sessionId = projectId || (await this.readProject({ projectName })).id; + + const baseBody = { + session_id: sessionId, + group_by: groupBy, + filter, + start_time: startTime ? startTime.toISOString() : null, + end_time: endTime ? endTime.toISOString() : null, + limit: Number(limit) || 100, + }; + + let currentOffset = Number(offset) || 0; + + const path = "/runs/group"; + const url = `${this.apiUrl}${path}`; + + while (true) { + const currentBody = { + ...baseBody, + offset: currentOffset, + }; + + // Remove undefined values from the payload + const filteredPayload = Object.fromEntries( + Object.entries(currentBody).filter(([_, value]) => value !== undefined), + ); + + const body = JSON.stringify(filteredPayload); + + const response = await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, `Failed to fetch ${path}`); + return res; + }); + + const items: { groups: Thread[]; total: number } = await response.json(); + const { groups, total } = items; + + if (groups.length === 0) { + break; + } + + for (const thread of groups) { + yield thread; + } + currentOffset += groups.length; + + if (currentOffset >= total) { + break; + } + } + } + + /** @deprecated Use `client.threads.listTraces()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-threads#threads-list-traces for the migration guide. Will be removed after Jan 31, 2027. */ + public async *readThread(props: ReadThreadParams): AsyncIterable { + warnOnce( + "readThread() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.threads.listTraces() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-threads#threads-list-traces for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_READ_THREAD" }, + ); + const { + threadId, + projectId, + projectName, + isRoot = true, + limit, + filter: userFilter, + order = "asc", + } = props; + + if (!projectId && !projectName) { + throw new Error("threadId requires projectId or projectName"); + } + + const threadFilter = `eq(thread_id, ${JSON.stringify(threadId)})`; + const combinedFilter = userFilter + ? `and(${threadFilter}, ${userFilter})` + : threadFilter; + + // Via _listRuns() rather than listRuns(): readThread() already warned, and it + // points at threads.listTraces() rather than listRuns()'s runs.query(). + yield* this._listRuns({ + projectId: projectId ?? undefined, + projectName: projectName ?? undefined, + isRoot, + limit, + filter: combinedFilter, + order, + }); + } + + /** @deprecated Use `client.threads.query()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-threads#threads-query for the migration guide. Will be removed after Jan 31, 2027. */ + public async listThreads( + props: ListThreadsParams, + ): Promise { + warnOnce( + "listThreads() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.threads.query() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-threads#threads-query for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_LIST_THREADS" }, + ); + const { + projectId, + projectName, + limit, + offset = 0, + filter, + startTime, + isRoot = true, + } = props; + + if (!projectId && !projectName) { + throw new Error("Either projectId or projectName must be provided"); + } + if (projectId && projectName) { + throw new Error("Provide exactly one of projectId or projectName"); + } + + const sessionId = + projectId ?? (await this.readProject({ projectName: projectName! })).id; + const startTimeResolved = + startTime ?? new Date(Date.now() - 1 * 24 * 60 * 60 * 1000); + + const runSelect = [ + "id", + "name", + "status", + "start_time", + "end_time", + "thread_id", + "trace_id", + "run_type", + "error", + "tags", + "session_id", + "parent_run_id", + "total_tokens", + "total_cost", + "dotted_order", + "reference_example_id", + "feedback_stats", + "app_path", + "completion_cost", + "completion_tokens", + "prompt_cost", + "prompt_tokens", + "first_token_time", + ]; + const bodyQuery: Record = { + session: [sessionId], + is_root: isRoot, + limit: 100, + order: "desc", + select: runSelect, + start_time: startTimeResolved.toISOString(), + }; + if (filter != null) { + bodyQuery.filter = filter; + } + + const threadsMap = new Map(); + for await (const runs of this._getCursorPaginatedList( + "/runs/query", + bodyQuery as RecordStringAny, + )) { + for (const raw of runs) { + const run = _normalizeRunTimestamps(raw); + const tid = (run as unknown as Record).thread_id as + | string + | undefined; + if (tid) { + const list = threadsMap.get(tid) ?? []; + list.push(run); + threadsMap.set(tid, list); + } + } + } + + const result: ListThreadsItem[] = []; + for (const [threadId, runs] of threadsMap.entries()) { + runs.sort((a, b) => { + const aRun = a as unknown as Record; + const bRun = b as unknown as Record; + const aStart = (aRun.start_time as string) ?? ""; + const bStart = (bRun.start_time as string) ?? ""; + if (aStart !== bStart) return aStart.localeCompare(bStart); + const aOrder = (aRun.dotted_order as string) ?? ""; + const bOrder = (bRun.dotted_order as string) ?? ""; + return aOrder.localeCompare(bOrder); + }); + const startTimes = runs + .map( + (r) => (r as unknown as Record).start_time as string, + ) + .filter(Boolean); + const sortedTimes = [...startTimes].sort(); + const minStart = sortedTimes.length ? sortedTimes[0] : ""; + const maxStart = sortedTimes.length + ? sortedTimes[sortedTimes.length - 1] + : ""; + result.push({ + thread_id: threadId, + runs, + count: runs.length, + filter: "", + total_tokens: 0, + total_cost: null, + min_start_time: minStart, + max_start_time: maxStart, + latency_p50: 0, + latency_p99: 0, + feedback_stats: null, + first_inputs: "", + last_outputs: "", + last_error: null, + } as ListThreadsItem); + } + + result.sort((a, b) => { + const aMax = a.max_start_time ?? ""; + const bMax = b.max_start_time ?? ""; + return bMax.localeCompare(aMax); + }); + const withOffset = offset > 0 ? result.slice(offset) : result; + const withLimit = + limit !== undefined ? withOffset.slice(0, limit) : withOffset; + return withLimit; + } + + public async getRunStats({ + id, + trace, + parentRun, + runType, + projectNames, + projectIds, + referenceExampleIds, + startTime, + endTime, + error, + query, + filter, + traceFilter, + treeFilter, + isRoot, + dataSourceType, + }: { + id?: string[]; + trace?: string; + parentRun?: string; + runType?: string; + projectNames?: string[]; + projectIds?: string[]; + referenceExampleIds?: string[]; + startTime?: string; + endTime?: string; + error?: boolean; + query?: string; + filter?: string; + traceFilter?: string; + treeFilter?: string; + isRoot?: boolean; + dataSourceType?: string; + }): Promise { + let projectIds_ = projectIds || []; + if (projectNames) { + projectIds_ = [ + ...(projectIds || []), + ...(await Promise.all( + projectNames.map((name) => + this.readProject({ projectName: name }).then( + (project) => project.id, + ), + ), + )), + ]; + } + if (projectIds_.length === 0) { + throw new Error( + "At least one of projectNames or projectIds must be provided.", + ); + } + + const payload = { + id, + trace, + parent_run: parentRun, + run_type: runType, + session: projectIds_, + reference_example: referenceExampleIds, + start_time: startTime, + end_time: endTime, + error, + query, + filter, + trace_filter: traceFilter, + tree_filter: treeFilter, + is_root: isRoot, + data_source_type: dataSourceType, + }; + + // Remove undefined values from the payload + const filteredPayload = Object.fromEntries( + Object.entries(payload).filter(([_, value]) => value !== undefined), + ); + + const body = JSON.stringify(filteredPayload); + + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/runs/stats`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "get run stats"); + return res; + }); + + const result = await response.json(); + return result; + } + + /** @deprecated Use `client.runs.share.create()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide. Will be removed after Jan 31, 2027. */ + public async shareRun( + runId: string, + { shareId }: { shareId?: string } = {}, + ): Promise { + warnOnce( + "shareRun() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.runs.share.create() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_SHARE_RUN" }, + ); + const data = { + run_id: runId, + share_token: shareId || uuid.v4(), + }; + assertUuid(runId); + const body = JSON.stringify(data); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/runs/${runId}/share`, { + method: "PUT", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "share run"); + return res; + }); + const result = await response.json(); + if (result === null || !("share_token" in result)) { + throw new Error("Invalid response from server"); + } + return `${this.getHostUrl()}/public/${result["share_token"]}/r`; + } + + /** @deprecated Use `client.runs.share.delete()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide. Will be removed after Jan 31, 2027. */ + public async unshareRun(runId: string): Promise { + warnOnce( + "unshareRun() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.runs.share.delete() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide.", + { type: "DeprecationWarning", code: "LANGSMITH_DEPRECATED_UNSHARE_RUN" }, + ); + assertUuid(runId); + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/runs/${runId}/share`, { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "unshare run", true); + return res; + }); + } + + /** @deprecated Use `client.runs.retrieve({ selects: ["SHARE_URL"] })` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide. Will be removed after Jan 31, 2027. */ + public async readRunSharedLink(runId: string): Promise { + warnOnce( + "readRunSharedLink() is deprecated and will be removed after Jan 31, 2027. " + + 'Use client.runs.retrieve({ selects: ["SHARE_URL"] }) instead. ' + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide.", + { + type: "DeprecationWarning", + code: "LANGSMITH_DEPRECATED_READ_RUN_SHARED_LINK", + }, + ); + assertUuid(runId); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/runs/${runId}/share`, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "read run shared link"); + return res; + }); + const result = await response.json(); + if (result === null || !("share_token" in result)) { + return undefined; + } + return `${this.getHostUrl()}/public/${result["share_token"]}/r`; + } + + /** @deprecated Use `client.public.runs.query()` instead. See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide. Will be removed after Jan 31, 2027. */ + public async listSharedRuns( + shareToken: string, + { + runIds, + }: { + runIds?: string[]; + } = {}, + ): Promise { + warnOnce( + "listSharedRuns() is deprecated and will be removed after Jan 31, 2027. " + + "Use client.public.runs.query() instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#share-and-read-public-runs for the migration guide.", + { + type: "DeprecationWarning", + code: "LANGSMITH_DEPRECATED_LIST_SHARED_RUNS", + }, + ); + const queryParams = new URLSearchParams({ + share_token: shareToken, + }); + if (runIds !== undefined) { + for (const runId of runIds) { + queryParams.append("id", runId); + } + } + assertUuid(shareToken); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/public/${shareToken}/runs${queryParams}`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "list shared runs"); + return res; + }); + const runs = (await response.json()) as Run[]; + return runs.map(_normalizeRunTimestamps); + } + + public async readDatasetSharedSchema( + datasetId?: string, + datasetName?: string, + ): Promise { + if (!datasetId && !datasetName) { + throw new Error("Either datasetId or datasetName must be given"); + } + if (!datasetId) { + const dataset = await this.readDataset({ datasetName }); + datasetId = dataset.id; + } + assertUuid(datasetId); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/datasets/${datasetId}/share`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "read dataset shared schema"); + return res; + }); + const shareSchema = await response.json(); + shareSchema.url = `${this.getHostUrl()}/public/${ + shareSchema.share_token + }/d`; + return shareSchema as DatasetShareSchema; + } + + public async shareDataset( + datasetId?: string, + datasetName?: string, + ): Promise { + if (!datasetId && !datasetName) { + throw new Error("Either datasetId or datasetName must be given"); + } + if (!datasetId) { + const dataset = await this.readDataset({ datasetName }); + datasetId = dataset.id; + } + const data = { + dataset_id: datasetId, + }; + assertUuid(datasetId); + const body = JSON.stringify(data); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/datasets/${datasetId}/share`, + { + method: "PUT", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "share dataset"); + return res; + }); + const shareSchema = await response.json(); + shareSchema.url = `${this.getHostUrl()}/public/${ + shareSchema.share_token + }/d`; + return shareSchema as DatasetShareSchema; + } + + public async unshareDataset(datasetId: string): Promise { + assertUuid(datasetId); + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/datasets/${datasetId}/share`, + { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "unshare dataset", true); + return res; + }); + } + + public async readSharedDataset(shareToken: string): Promise { + assertUuid(shareToken); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/public/${shareToken}/datasets`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "read shared dataset"); + return res; + }); + const dataset = await response.json(); + return dataset as Dataset; + } + + /** + * Get shared examples. + * + * @param {string} shareToken The share token to get examples for. A share token is the UUID (or LangSmith URL, including UUID) generated when explicitly marking an example as public. + * @param {Object} [options] Additional options for listing the examples. + * @param {string[] | undefined} [options.exampleIds] A list of example IDs to filter by. + * @returns {Promise} The shared examples. + */ + public async listSharedExamples( + shareToken: string, + options?: { exampleIds?: string[] }, + ): Promise { + const params: Record = {}; + if (options?.exampleIds) { + params.id = options.exampleIds; + } + + const urlParams = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach((v) => urlParams.append(key, v)); + } else { + urlParams.append(key, value); + } + }); + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/public/${shareToken}/examples?${urlParams.toString()}`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "list shared examples"); + return res; + }); + const result = await response.json(); + if (!response.ok) { + if ("detail" in result) { + throw new Error( + `Failed to list shared examples.\nStatus: ${ + response.status + }\nMessage: ${ + Array.isArray(result.detail) + ? result.detail.join("\n") + : "Unspecified error" + }`, + ); + } + throw new Error( + `Failed to list shared examples: ${response.status} ${response.statusText}`, + ); + } + return result.map((example: any) => ({ + ...example, + _hostUrl: this.getHostUrl(), + })); + } + + public async createProject({ + projectName, + description = null, + metadata = null, + upsert = false, + projectExtra = null, + referenceDatasetId = null, + numExamples = null, + numRepetitions = null, + evaluatorKeys = null, + tagValueIds = null, + }: CreateProjectParams): Promise { + const upsert_ = upsert ? `?upsert=true` : ""; + const endpoint = `${this.apiUrl}/sessions${upsert_}`; + const extra: RecordStringAny = projectExtra || {}; + if (metadata) { + extra["metadata"] = metadata; + } + const body: RecordStringAny = { + name: projectName, + extra, + description, + }; + if (referenceDatasetId !== null) { + body["reference_dataset_id"] = referenceDatasetId; + } + if (numExamples != null) { + body["num_examples"] = numExamples; + } + if (numRepetitions != null) { + body["num_repetitions"] = numRepetitions; + } + if (evaluatorKeys != null && evaluatorKeys.length > 0) { + body["evaluator_keys"] = evaluatorKeys; + } + if (tagValueIds !== null) { + body["tag_value_ids"] = tagValueIds; + } + const serializedBody = JSON.stringify(body); + const response = await this.caller.call(async () => { + const res = await this._fetch(endpoint, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: serializedBody, + }); + await raiseForStatus(res, "create project"); + return res; + }); + const result = await response.json(); + return result as TracerSession; + } + + public async updateProject( + projectId: string, + { + name = null, + description = null, + metadata = null, + projectExtra = null, + endTime = null, + }: { + name?: string | null; + description?: string | null; + metadata?: RecordStringAny | null; + projectExtra?: RecordStringAny | null; + endTime?: string | null; + }, + ): Promise { + const endpoint = `${this.apiUrl}/sessions/${projectId}`; + let extra = projectExtra; + if (metadata) { + extra = { ...(extra || {}), metadata }; + } + const body = JSON.stringify({ + name, + extra, + description, + end_time: endTime ? new Date(endTime).toISOString() : null, + }); + const response = await this.caller.call(async () => { + const res = await this._fetch(endpoint, { + method: "PATCH", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "update project"); + return res; + }); + const result = await response.json(); + return result as TracerSession; + } + + public async hasProject({ + projectId, + projectName, + }: { + projectId?: string; + projectName?: string; + }): Promise { + // TODO: Add a head request + let path = "/sessions"; + const params = new URLSearchParams(); + if (projectId !== undefined && projectName !== undefined) { + throw new Error("Must provide either projectName or projectId, not both"); + } else if (projectId !== undefined) { + assertUuid(projectId); + path += `/${projectId}`; + } else if (projectName !== undefined) { + params.append("name", projectName); + } else { + throw new Error("Must provide projectName or projectId"); + } + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}${path}?${params}`, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "has project"); + return res; + }); + // consume the response body to release the connection + // https://undici.nodejs.org/#/?id=garbage-collection + try { + const result = await response.json(); + if (!response.ok) { + return false; + } + // If it's OK and we're querying by name, need to check the list is not empty + if (Array.isArray(result)) { + return result.length > 0; + } + // projectId querying + return true; + } catch (_e) { + return false; + } + } + + public async readProject({ + projectId, + projectName, + includeStats, + }: { + projectId?: string; + projectName?: string; + includeStats?: boolean; + }): Promise { + let path = "/sessions"; + const params = new URLSearchParams(); + if (projectId !== undefined && projectName !== undefined) { + throw new Error("Must provide either projectName or projectId, not both"); + } else if (projectId !== undefined) { + assertUuid(projectId); + path += `/${projectId}`; + } else if (projectName !== undefined) { + params.append("name", projectName); + } else { + throw new Error("Must provide projectName or projectId"); + } + if (includeStats !== undefined) { + params.append("include_stats", includeStats.toString()); + } + + const response = await this._get( + path, + params, + ); + let result: TracerSession; + if (Array.isArray(response)) { + if (response.length === 0) { + throw new Error( + `Project[id=${projectId}, name=${projectName}] not found`, + ); + } + result = response[0] as TracerSessionResult; + } else { + result = response as TracerSessionResult; + } + return result; + } + + public async getProjectUrl({ + projectId, + projectName, + }: { + projectId?: string; + projectName?: string; + }) { + if (projectId === undefined && projectName === undefined) { + throw new Error("Must provide either projectName or projectId"); + } + const project = await this.readProject({ projectId, projectName }); + const tenantId = await this._getTenantId(); + return `${this.getHostUrl()}/o/${tenantId}/projects/p/${project.id}`; + } + + public async getDatasetUrl({ + datasetId, + datasetName, + }: { + datasetId?: string; + datasetName?: string; + }) { + if (datasetId === undefined && datasetName === undefined) { + throw new Error("Must provide either datasetName or datasetId"); + } + const dataset = await this.readDataset({ datasetId, datasetName }); + const tenantId = await this._getTenantId(); + return `${this.getHostUrl()}/o/${tenantId}/datasets/${dataset.id}`; + } + + private async _getTenantId(): Promise { + if (this._tenantId !== null) { + return this._tenantId; + } + const queryParams = new URLSearchParams({ limit: "1" }); + for await (const projects of this._getPaginated( + "/sessions", + queryParams, + )) { + this._tenantId = projects[0].tenant_id; + return projects[0].tenant_id; + } + throw new Error("No projects found to resolve tenant."); + } + + public async *listProjects({ + projectIds, + name, + nameContains, + referenceDatasetId, + referenceDatasetName, + includeStats, + datasetVersion, + referenceFree, + metadata, + }: { + projectIds?: string[]; + name?: string; + nameContains?: string; + referenceDatasetId?: string; + referenceDatasetName?: string; + includeStats?: boolean; + datasetVersion?: string; + referenceFree?: boolean; + metadata?: RecordStringAny; + } = {}): AsyncIterable { + const params = new URLSearchParams(); + if (projectIds !== undefined) { + for (const projectId of projectIds) { + params.append("id", projectId); + } + } + if (name !== undefined) { + params.append("name", name); + } + if (nameContains !== undefined) { + params.append("name_contains", nameContains); + } + if (referenceDatasetId !== undefined) { + params.append("reference_dataset", referenceDatasetId); + } else if (referenceDatasetName !== undefined) { + const dataset = await this.readDataset({ + datasetName: referenceDatasetName, + }); + params.append("reference_dataset", dataset.id); + } + if (includeStats !== undefined) { + params.append("include_stats", includeStats.toString()); + } + if (datasetVersion !== undefined) { + params.append("dataset_version", datasetVersion); + } + if (referenceFree !== undefined) { + params.append("reference_free", referenceFree.toString()); + } + if (metadata !== undefined) { + params.append("metadata", JSON.stringify(metadata)); + } + for await (const projects of this._getPaginated( + "/sessions", + params, + )) { + yield* projects; + } + } + + public async deleteProject({ + projectId, + projectName, + }: { + projectId?: string; + projectName?: string; + }): Promise { + let projectId_: string | undefined; + if (projectId === undefined && projectName === undefined) { + throw new Error("Must provide projectName or projectId"); + } else if (projectId !== undefined && projectName !== undefined) { + throw new Error("Must provide either projectName or projectId, not both"); + } else if (projectId === undefined) { + projectId_ = (await this.readProject({ projectName })).id; + } else { + projectId_ = projectId; + } + assertUuid(projectId_); + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/sessions/${projectId_}`, { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus( + res, + `delete session ${projectId_} (${projectName})`, + true, + ); + return res; + }); + } + + public async uploadCsv({ + csvFile, + fileName, + inputKeys, + outputKeys, + description, + dataType, + name, + }: UploadCSVParams): Promise { + const url = `${this.apiUrl}/datasets/upload`; + const formData = new FormData(); + const csvBlob = new Blob([csvFile], { type: "text/csv" }); + formData.append("file", csvBlob, fileName); + inputKeys.forEach((key) => { + formData.append("input_keys", key); + }); + + outputKeys.forEach((key) => { + formData.append("output_keys", key); + }); + if (description) { + formData.append("description", description); + } + if (dataType) { + formData.append("data_type", dataType); + } + if (name) { + formData.append("name", name); + } + + const response = await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "POST", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: formData, + }); + await raiseForStatus(res, "upload CSV"); + return res; + }); + + const result = await response.json(); + return result as Dataset; + } + + public async createDataset( + name: string, + { + description, + dataType, + inputsSchema, + outputsSchema, + metadata, + tagValueIds, + }: { + description?: string; + dataType?: DataType; + inputsSchema?: KVMap; + outputsSchema?: KVMap; + metadata?: RecordStringAny; + tagValueIds?: string[]; + } = {}, + ): Promise { + const body: KVMap = { + name, + description, + extra: { source: "sdk", ...(metadata ? { metadata } : {}) }, + }; + if (dataType) { + body.data_type = dataType; + } + if (inputsSchema) { + body.inputs_schema_definition = inputsSchema; + } + if (outputsSchema) { + body.outputs_schema_definition = outputsSchema; + } + if (tagValueIds !== undefined) { + body.tag_value_ids = tagValueIds; + } + const serializedBody = JSON.stringify(body); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/datasets`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: serializedBody, + }); + await raiseForStatus(res, "create dataset"); + return res; + }); + const result = await response.json(); + return result as Dataset; + } + + public async readDataset({ + datasetId, + datasetName, + }: { + datasetId?: string; + datasetName?: string; + }): Promise { + let path = "/datasets"; + // limit to 1 result + const params = new URLSearchParams({ limit: "1" }); + if (datasetId && datasetName) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId) { + assertUuid(datasetId); + path += `/${datasetId}`; + } else if (datasetName) { + params.append("name", datasetName); + } else { + throw new Error("Must provide datasetName or datasetId"); + } + const response = await this._get(path, params); + let result: Dataset; + if (Array.isArray(response)) { + if (response.length === 0) { + throw new Error( + `Dataset[id=${datasetId}, name=${datasetName}] not found`, + ); + } + result = response[0] as Dataset; + } else { + result = response as Dataset; + } + return result; + } + + public async hasDataset({ + datasetId, + datasetName, + }: { + datasetId?: string; + datasetName?: string; + }): Promise { + try { + await this.readDataset({ datasetId, datasetName }); + return true; + } catch (e) { + if ( + e instanceof Error && + e.message.toLocaleLowerCase().includes("not found") + ) { + return false; + } + throw e; + } + } + + public async diffDatasetVersions({ + datasetId, + datasetName, + fromVersion, + toVersion, + }: { + datasetId?: string; + datasetName?: string; + fromVersion: string | Date; + toVersion: string | Date; + }): Promise { + let datasetId_ = datasetId; + if (datasetId_ === undefined && datasetName === undefined) { + throw new Error("Must provide either datasetName or datasetId"); + } else if (datasetId_ !== undefined && datasetName !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId_ === undefined) { + const dataset = await this.readDataset({ datasetName }); + datasetId_ = dataset.id; + } + const urlParams = new URLSearchParams({ + from_version: + typeof fromVersion === "string" + ? fromVersion + : fromVersion.toISOString(), + to_version: + typeof toVersion === "string" ? toVersion : toVersion.toISOString(), + }); + const response = await this._get( + `/datasets/${datasetId_}/versions/diff`, + urlParams, + ); + return response as DatasetDiffInfo; + } + + public async readDatasetOpenaiFinetuning({ + datasetId, + datasetName, + }: { + datasetId?: string; + datasetName?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }): Promise { + const path = "/datasets"; + if (datasetId !== undefined) { + // do nothing + } else if (datasetName !== undefined) { + datasetId = (await this.readDataset({ datasetName })).id; + } else { + throw new Error("Must provide either datasetName or datasetId"); + } + const response = await this._getResponse(`${path}/${datasetId}/openai_ft`); + const datasetText = await response.text(); + const dataset = datasetText + .trim() + .split("\n") + .map((line: string) => JSON.parse(line)); + return dataset; + } + + public async *listDatasets({ + limit = 100, + offset = 0, + datasetIds, + datasetName, + datasetNameContains, + metadata, + }: { + limit?: number; + offset?: number; + datasetIds?: string[]; + datasetName?: string; + datasetNameContains?: string; + metadata?: RecordStringAny; + } = {}): AsyncIterable { + const path = "/datasets"; + const params = new URLSearchParams({ + limit: limit.toString(), + offset: offset.toString(), + }); + if (datasetIds !== undefined) { + for (const id_ of datasetIds) { + params.append("id", id_); + } + } + if (datasetName !== undefined) { + params.append("name", datasetName); + } + if (datasetNameContains !== undefined) { + params.append("name_contains", datasetNameContains); + } + if (metadata !== undefined) { + params.append("metadata", JSON.stringify(metadata)); + } + for await (const datasets of this._getPaginated(path, params)) { + yield* datasets; + } + } + + /** + * Update a dataset + * @param props The dataset details to update + * @returns The updated dataset + */ + public async updateDataset(props: { + datasetId?: string; + datasetName?: string; + name?: string; + description?: string; + }): Promise { + const { datasetId, datasetName, ...update } = props; + + if (!datasetId && !datasetName) { + throw new Error("Must provide either datasetName or datasetId"); + } + const _datasetId = + datasetId ?? (await this.readDataset({ datasetName })).id; + assertUuid(_datasetId); + + const body = JSON.stringify(update); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/datasets/${_datasetId}`, { + method: "PATCH", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "update dataset"); + return res; + }); + return (await response.json()) as Dataset; + } + + /** + * Updates a tag on a dataset. + * + * If the tag is already assigned to a different version of this dataset, + * the tag will be moved to the new version. The as_of parameter is used to + * determine which version of the dataset to apply the new tags to. + * + * It must be an exact version of the dataset to succeed. You can + * use the "readDatasetVersion" method to find the exact version + * to apply the tags to. + * @param params.datasetId The ID of the dataset to update. Must be provided if "datasetName" is not provided. + * @param params.datasetName The name of the dataset to update. Must be provided if "datasetId" is not provided. + * @param params.asOf The timestamp of the dataset to apply the new tags to. + * @param params.tag The new tag to apply to the dataset. + */ + public async updateDatasetTag(props: { + datasetId?: string; + datasetName?: string; + asOf: string | Date; + tag: string; + }): Promise { + const { datasetId, datasetName, asOf, tag } = props; + + if (!datasetId && !datasetName) { + throw new Error("Must provide either datasetName or datasetId"); + } + const _datasetId = + datasetId ?? (await this.readDataset({ datasetName })).id; + assertUuid(_datasetId); + + const body = JSON.stringify({ + as_of: typeof asOf === "string" ? asOf : asOf.toISOString(), + tag, + }); + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/datasets/${_datasetId}/tags`, + { + method: "PUT", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "update dataset tags", true); + return res; + }); + } + + public async deleteDataset({ + datasetId, + datasetName, + }: { + datasetId?: string; + datasetName?: string; + }): Promise { + let path = "/datasets"; + let datasetId_ = datasetId; + if (datasetId !== undefined && datasetName !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetName !== undefined) { + const dataset = await this.readDataset({ datasetName }); + datasetId_ = dataset.id; + } + if (datasetId_ !== undefined) { + assertUuid(datasetId_); + path += `/${datasetId_}`; + } else { + throw new Error("Must provide datasetName or datasetId"); + } + await this.caller.call(async () => { + const res = await this._fetch(this.apiUrl + path, { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, `delete ${path}`, true); + return res; + }); + } + + public async createExample(update: ExampleCreate): Promise; + + /** + * @deprecated This signature is deprecated, use createExample(update: ExampleCreate) instead + */ + public async createExample( + inputs: KVMap, + outputs: KVMap, + options: CreateExampleOptions, + ): Promise; + + public async createExample( + inputsOrUpdate: KVMap | ExampleCreate, + outputs?: KVMap, + options?: CreateExampleOptions, + ): Promise { + if (isExampleCreate(inputsOrUpdate)) { + if (outputs !== undefined || options !== undefined) { + throw new Error( + "Cannot provide outputs or options when using ExampleCreate object", + ); + } + } + + let datasetId_ = outputs ? options?.datasetId : inputsOrUpdate.dataset_id; + const datasetName_ = outputs + ? options?.datasetName + : inputsOrUpdate.dataset_name; + if (datasetId_ === undefined && datasetName_ === undefined) { + throw new Error("Must provide either datasetName or datasetId"); + } else if (datasetId_ !== undefined && datasetName_ !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId_ === undefined) { + const dataset = await this.readDataset({ datasetName: datasetName_ }); + datasetId_ = dataset.id; + } + + const createdAt_ = + (outputs ? options?.createdAt : inputsOrUpdate.created_at) || new Date(); + let data: ExampleCreate; + if (!isExampleCreate(inputsOrUpdate)) { + data = { + inputs: inputsOrUpdate, + outputs, + created_at: createdAt_?.toISOString(), + id: options?.exampleId, + metadata: options?.metadata, + split: options?.split, + source_run_id: options?.sourceRunId, + use_source_run_io: options?.useSourceRunIO, + use_source_run_attachments: options?.useSourceRunAttachments, + attachments: options?.attachments, + }; + } else { + data = inputsOrUpdate; + } + + const response = await this._uploadExamplesMultipart(datasetId_, [data]); + const example = await this.readExample( + response.example_ids?.[0] ?? uuid.v4(), + ); + return example; + } + + public async createExamples(uploads: ExampleCreate[]): Promise; + /** @deprecated Use the uploads-only overload instead */ + public async createExamples(props: { + inputs?: Array; + outputs?: Array; + metadata?: Array; + splits?: Array>; + sourceRunIds?: Array; + useSourceRunIOs?: Array; + useSourceRunAttachments?: Array; + attachments?: Array; + exampleIds?: Array; + datasetId?: string; + datasetName?: string; + }): Promise; + public async createExamples( + propsOrUploads: + | ExampleCreate[] + | { + inputs?: Array; + outputs?: Array; + metadata?: Array; + splits?: Array>; + sourceRunIds?: Array; + useSourceRunIOs?: Array; + useSourceRunAttachments?: Array; + attachments?: Array; + exampleIds?: Array; + datasetId?: string; + datasetName?: string; + }, + ): Promise { + if (Array.isArray(propsOrUploads)) { + if (propsOrUploads.length === 0) { + return []; + } + + const uploads = propsOrUploads; + let datasetId_ = uploads[0].dataset_id; + const datasetName_ = uploads[0].dataset_name; + + if (datasetId_ === undefined && datasetName_ === undefined) { + throw new Error("Must provide either datasetName or datasetId"); + } else if (datasetId_ !== undefined && datasetName_ !== undefined) { + throw new Error( + "Must provide either datasetName or datasetId, not both", + ); + } else if (datasetId_ === undefined) { + const dataset = await this.readDataset({ datasetName: datasetName_ }); + datasetId_ = dataset.id; + } + + const response = await this._uploadExamplesMultipart(datasetId_, uploads); + const examples = await Promise.all( + response.example_ids.map((id) => this.readExample(id)), + ); + return examples; + } + + const { + inputs, + outputs, + metadata, + splits, + sourceRunIds, + useSourceRunIOs, + useSourceRunAttachments, + attachments, + exampleIds, + datasetId, + datasetName, + } = propsOrUploads; + + if (inputs === undefined) { + throw new Error("Must provide inputs when using legacy parameters"); + } + + let datasetId_ = datasetId; + const datasetName_ = datasetName; + + if (datasetId_ === undefined && datasetName_ === undefined) { + throw new Error("Must provide either datasetName or datasetId"); + } else if (datasetId_ !== undefined && datasetName_ !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId_ === undefined) { + const dataset = await this.readDataset({ datasetName: datasetName_ }); + datasetId_ = dataset.id; + } + + const formattedExamples: ExampleCreate[] = inputs.map((input, idx) => { + return { + dataset_id: datasetId_, + inputs: input, + outputs: outputs?.[idx], + metadata: metadata?.[idx], + split: splits?.[idx], + id: exampleIds?.[idx], + attachments: attachments?.[idx], + source_run_id: sourceRunIds?.[idx], + use_source_run_io: useSourceRunIOs?.[idx], + use_source_run_attachments: useSourceRunAttachments?.[idx], + } as ExampleCreate; + }); + + const response = await this._uploadExamplesMultipart( + datasetId_, + formattedExamples, + ); + const examples = await Promise.all( + response.example_ids.map((id) => this.readExample(id)), + ); + return examples; + } + + public async createLLMExample( + input: string, + generation: string | undefined, + options: CreateExampleOptions, + ) { + return this.createExample({ input }, { output: generation }, options); + } + + public async createChatExample( + input: KVMap[] | LangChainBaseMessage[], + generations: KVMap | LangChainBaseMessage | undefined, + options: CreateExampleOptions, + ) { + const finalInput = input.map((message) => { + if (isLangChainMessage(message)) { + return convertLangChainMessageToExample(message); + } + return message; + }); + const finalOutput = isLangChainMessage(generations) + ? convertLangChainMessageToExample(generations) + : generations; + return this.createExample( + { input: finalInput }, + { output: finalOutput }, + options, + ); + } + + public async readExample(exampleId: string): Promise { + assertUuid(exampleId); + const path = `/examples/${exampleId}`; + const rawExample: RawExample = await this._get(path); + const { attachment_urls, ...rest } = rawExample; + const example: Example = rest; + if (attachment_urls) { + example.attachments = Object.entries(attachment_urls).reduce( + (acc, [key, value]) => { + acc[key.slice("attachment.".length)] = { + presigned_url: new URL(value.presigned_url, this.apiUrl).href, + mime_type: value.mime_type, + }; + return acc; + }, + {} as Record, + ); + } + return example; + } + + public async *listExamples({ + datasetId, + datasetName, + exampleIds, + asOf, + splits, + inlineS3Urls, + metadata, + limit, + offset, + filter, + includeAttachments, + }: { + datasetId?: string; + datasetName?: string; + exampleIds?: string[]; + asOf?: string | Date; + splits?: string[]; + inlineS3Urls?: boolean; + metadata?: KVMap; + limit?: number; + offset?: number; + filter?: string; + includeAttachments?: boolean; + } = {}): AsyncIterable { + let datasetId_; + if (datasetId !== undefined && datasetName !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId !== undefined) { + datasetId_ = datasetId; + } else if (datasetName !== undefined) { + const dataset = await this.readDataset({ datasetName }); + datasetId_ = dataset.id; + } else { + throw new Error("Must provide a datasetName or datasetId"); + } + const params = new URLSearchParams({ dataset: datasetId_ }); + const dataset_version = asOf + ? typeof asOf === "string" + ? asOf + : asOf?.toISOString() + : undefined; + if (dataset_version) { + params.append("as_of", dataset_version); + } + const inlineS3Urls_ = inlineS3Urls ?? true; + params.append("inline_s3_urls", inlineS3Urls_.toString()); + if (exampleIds !== undefined) { + for (const id_ of exampleIds) { + params.append("id", id_); + } + } + if (splits !== undefined) { + for (const split of splits) { + params.append("splits", split); + } + } + if (metadata !== undefined) { + const serializedMetadata = JSON.stringify(metadata); + params.append("metadata", serializedMetadata); + } + if (limit !== undefined) { + params.append("limit", limit.toString()); + } + if (offset !== undefined) { + params.append("offset", offset.toString()); + } + if (filter !== undefined) { + params.append("filter", filter); + } + if (includeAttachments === true) { + ["attachment_urls", "outputs", "metadata"].forEach((field) => + params.append("select", field), + ); + } + let i = 0; + for await (const rawExamples of this._getPaginated( + "/examples", + params, + )) { + for (const rawExample of rawExamples) { + const { attachment_urls, ...rest } = rawExample; + const example: Example = rest; + if (attachment_urls) { + example.attachments = Object.entries(attachment_urls).reduce( + (acc, [key, value]) => { + acc[key.slice("attachment.".length)] = { + presigned_url: new URL(value.presigned_url, this.apiUrl).href, + mime_type: value.mime_type || undefined, + }; + return acc; + }, + {} as Record, + ); + } + yield example; + i++; + } + if (limit !== undefined && i >= limit) { + break; + } + } + } + + public async deleteExample(exampleId: string): Promise { + assertUuid(exampleId); + const path = `/examples/${exampleId}`; + await this.caller.call(async () => { + const res = await this._fetch(this.apiUrl + path, { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, `delete ${path}`, true); + return res; + }); + } + + /** + * Delete multiple examples by ID. + * @param exampleIds - The IDs of the examples to delete + * @param options - Optional settings for deletion + * @param options.hardDelete - If true, permanently delete examples. If false (default), soft delete them. + */ + public async deleteExamples( + exampleIds: string[], + options?: { hardDelete?: boolean }, + ): Promise { + // Validate all UUIDs + exampleIds.forEach((id) => assertUuid(id)); + + if (options?.hardDelete) { + // Hard delete uses POST to a different platform endpoint + const path = this._getPlatformEndpointPath("datasets/examples/delete"); + + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}${path}`, { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + example_ids: exampleIds, + hard_delete: true, + }), + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "hard delete examples", true); + return res; + }); + } else { + // Soft delete uses DELETE with query params + const params = new URLSearchParams(); + exampleIds.forEach((id) => params.append("example_ids", id)); + + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/examples?${params.toString()}`, + { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete examples", true); + return res; + }); + } + } + + /** + * @deprecated This signature is deprecated, use updateExample(update: ExampleUpdate) instead + */ + public async updateExample( + exampleId: string, + update: ExampleUpdateWithoutId, + ): Promise; + + public async updateExample(update: ExampleUpdate): Promise; + + public async updateExample( + exampleIdOrUpdate: string | ExampleUpdate, + update?: ExampleUpdateWithoutId, + ): Promise { + let exampleId: string; + if (update) { + exampleId = exampleIdOrUpdate as string; + } else { + exampleId = (exampleIdOrUpdate as ExampleUpdate).id; + } + + assertUuid(exampleId); + + let updateToUse: ExampleUpdate; + if (update) { + updateToUse = { id: exampleId, ...update }; + } else { + updateToUse = exampleIdOrUpdate as ExampleUpdate; + } + + let datasetId: string; + if (updateToUse.dataset_id !== undefined) { + datasetId = updateToUse.dataset_id; + } else { + const example = await this.readExample(exampleId); + datasetId = example.dataset_id; + } + + return this._updateExamplesMultipart(datasetId, [updateToUse]); + } + + public async updateExamples(update: ExampleUpdate[]): Promise { + // We will naively get dataset id from first example and assume it works for all + let datasetId: string; + if (update[0].dataset_id === undefined) { + const example = await this.readExample(update[0].id); + datasetId = example.dataset_id; + } else { + datasetId = update[0].dataset_id; + } + + return this._updateExamplesMultipart(datasetId, update); + } + + /** + * Get dataset version by closest date or exact tag. + * + * Use this to resolve the nearest version to a given timestamp or for a given tag. + * + * @param options The options for getting the dataset version + * @param options.datasetId The ID of the dataset + * @param options.datasetName The name of the dataset + * @param options.asOf The timestamp of the dataset to retrieve + * @param options.tag The tag of the dataset to retrieve + * @returns The dataset version + */ + public async readDatasetVersion({ + datasetId, + datasetName, + asOf, + tag, + }: { + datasetId?: string; + datasetName?: string; + asOf?: string | Date; + tag?: string; + }): Promise { + let resolvedDatasetId: string; + if (!datasetId) { + const dataset = await this.readDataset({ datasetName }); + resolvedDatasetId = dataset.id; + } else { + resolvedDatasetId = datasetId; + } + + assertUuid(resolvedDatasetId); + + if ((asOf && tag) || (!asOf && !tag)) { + throw new Error("Exactly one of asOf and tag must be specified."); + } + + const params = new URLSearchParams(); + if (asOf !== undefined) { + params.append( + "as_of", + typeof asOf === "string" ? asOf : asOf.toISOString(), + ); + } + if (tag !== undefined) { + params.append("tag", tag); + } + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${ + this.apiUrl + }/datasets/${resolvedDatasetId}/version?${params.toString()}`, + { + method: "GET", + headers: { ...this._mergedHeaders }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "read dataset version"); + return res; + }); + + return await response.json(); + } + + public async listDatasetSplits({ + datasetId, + datasetName, + asOf, + }: { + datasetId?: string; + datasetName?: string; + asOf?: string | Date; + }): Promise { + let datasetId_: string; + if (datasetId === undefined && datasetName === undefined) { + throw new Error("Must provide dataset name or ID"); + } else if (datasetId !== undefined && datasetName !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId === undefined) { + const dataset = await this.readDataset({ datasetName }); + datasetId_ = dataset.id; + } else { + datasetId_ = datasetId; + } + + assertUuid(datasetId_); + + const params = new URLSearchParams(); + const dataset_version = asOf + ? typeof asOf === "string" + ? asOf + : asOf?.toISOString() + : undefined; + if (dataset_version) { + params.append("as_of", dataset_version); + } + + const response = await this._get( + `/datasets/${datasetId_}/splits`, + params, + ); + return response; + } + + public async updateDatasetSplits({ + datasetId, + datasetName, + splitName, + exampleIds, + remove = false, + }: { + datasetId?: string; + datasetName?: string; + splitName: string; + exampleIds: string[]; + remove?: boolean; + }): Promise { + let datasetId_: string; + if (datasetId === undefined && datasetName === undefined) { + throw new Error("Must provide dataset name or ID"); + } else if (datasetId !== undefined && datasetName !== undefined) { + throw new Error("Must provide either datasetName or datasetId, not both"); + } else if (datasetId === undefined) { + const dataset = await this.readDataset({ datasetName }); + datasetId_ = dataset.id; + } else { + datasetId_ = datasetId; + } + + assertUuid(datasetId_); + + const data = { + split_name: splitName, + examples: exampleIds.map((id) => { + assertUuid(id); + return id; + }), + remove, + }; + + const body = JSON.stringify(data); + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/datasets/${datasetId_}/splits`, + { + method: "PUT", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "update dataset splits", true); + return res; + }); + } + + public async createFeedback(params: CreateFeedbackParams): Promise; + /** @deprecated Pass all params within an object and populate sessionId. */ + public async createFeedback( + runId: string | null, + key: string, + options: CreateFeedbackLegacyOptions, + ): Promise; + public async createFeedback( + runIdOrParams: string | null | CreateFeedbackParams, + keyArg?: string, + optionsArg?: CreateFeedbackLegacyOptions, + ): Promise { + const { + runId = null, + key, + score, + value, + correction, + comment, + sourceInfo, + feedbackSourceType = "api", + sourceRunId, + feedbackId, + feedbackConfig, + projectId, + traceId, + comparativeExperimentId, + sessionId, + startTime, + extendTraceRetention, + } = typeof runIdOrParams === "object" && runIdOrParams !== null + ? runIdOrParams + : { runId: runIdOrParams, key: keyArg as string, ...optionsArg }; + if (!runId && !projectId) { + throw new Error("One of runId or projectId must be provided"); + } + if (runId && projectId) { + throw new Error("Only one of runId or projectId can be provided"); + } + if (runId && sessionId === undefined) { + await this._checkFeedbackSessionId(); + } + const feedback_source: feedback_source = { + type: feedbackSourceType ?? "api", + metadata: sourceInfo ?? {}, + }; + if ( + sourceRunId !== undefined && + feedback_source?.metadata !== undefined && + !feedback_source.metadata["__run"] + ) { + feedback_source.metadata["__run"] = { run_id: sourceRunId }; + } + if ( + feedback_source?.metadata !== undefined && + feedback_source.metadata["__run"]?.run_id !== undefined + ) { + assertUuid(feedback_source.metadata["__run"].run_id); + } + const feedback: FeedbackCreate = { + id: feedbackId ?? uuid.v7(), + run_id: runId, + trace_id: traceId, + key, + score: _formatFeedbackScore(score), + value, + correction, + comment, + feedback_source: feedback_source, + comparative_experiment_id: comparativeExperimentId, + feedbackConfig, + session_id: sessionId ?? projectId, + start_time: startTime, + extend_trace_retention: extendTraceRetention, + }; + const samplingId = traceId ?? runId; + if (samplingId != null && !this._shouldSample(samplingId)) { + return feedback as Feedback; + } + const body = JSON.stringify(feedback); + const url = `${this.apiUrl}/feedback`; + await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "create feedback", true); + return res; + }); + return feedback as Feedback; + } + + public async updateFeedback( + feedbackId: string, + { + score, + value, + correction, + comment, + }: { + score?: number | boolean | null; + value?: number | boolean | string | object | null; + correction?: object | null; + comment?: string | null; + }, + ): Promise { + const feedbackUpdate: FeedbackUpdate = {}; + if (score !== undefined && score !== null) { + feedbackUpdate["score"] = _formatFeedbackScore(score); + } + if (value !== undefined && value !== null) { + feedbackUpdate["value"] = value; + } + if (correction !== undefined && correction !== null) { + feedbackUpdate["correction"] = correction; + } + if (comment !== undefined && comment !== null) { + feedbackUpdate["comment"] = comment; + } + assertUuid(feedbackId); + const body = JSON.stringify(feedbackUpdate); + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/feedback/${feedbackId}`, { + method: "PATCH", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "update feedback", true); + return res; + }); + } + + public async readFeedback(feedbackId: string): Promise { + assertUuid(feedbackId); + const path = `/feedback/${feedbackId}`; + const response = await this._get(path); + return response; + } + + public async deleteFeedback(feedbackId: string): Promise { + assertUuid(feedbackId); + const path = `/feedback/${feedbackId}`; + await this.caller.call(async () => { + const res = await this._fetch(this.apiUrl + path, { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, `delete ${path}`, true); + return res; + }); + } + + public async *listFeedback({ + runIds, + feedbackKeys, + feedbackSourceTypes, + }: { + runIds?: string[]; + feedbackKeys?: string[]; + feedbackSourceTypes?: FeedbackSourceType[]; + } = {}): AsyncIterable { + const queryParams = new URLSearchParams(); + if (runIds) { + for (const runId of runIds) { + assertUuid(runId); + queryParams.append("run", runId); + } + } + if (feedbackKeys) { + for (const key of feedbackKeys) { + queryParams.append("key", key); + } + } + if (feedbackSourceTypes) { + for (const type of feedbackSourceTypes) { + queryParams.append("source", type); + } + } + for await (const feedbacks of this._getPaginated( + "/feedback", + queryParams, + )) { + yield* feedbacks; + } + } + + /** + * Creates a presigned feedback token and URL. + * + * The token can be used to authorize feedback metrics without + * needing an API key. This is useful for giving browser-based + * applications the ability to submit feedback without needing + * to expose an API key. + * + * @param runId The ID of the run. + * @param feedbackKey The feedback key. + * @param options Additional options for the token. + * @param options.expiration The expiration time for the token. + * + * @returns A promise that resolves to a FeedbackIngestToken. + */ + public async createPresignedFeedbackToken( + runId: string, + feedbackKey: string, + { + expiration, + feedbackConfig, + }: { + expiration?: string | TimeDelta; + feedbackConfig?: FeedbackConfig; + } = {}, + ): Promise { + const body: KVMap = { + run_id: runId, + feedback_key: feedbackKey, + feedback_config: feedbackConfig, + }; + if (expiration) { + if (typeof expiration === "string") { + body["expires_at"] = expiration; + } else if (expiration?.hours || expiration?.minutes || expiration?.days) { + body["expires_in"] = expiration; + } + } else { + body["expires_in"] = { + hours: 3, + }; + } + + const serializedBody = JSON.stringify(body); + + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/feedback/tokens`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: serializedBody, + }); + await raiseForStatus(res, "create presigned feedback token"); + return res; + }); + return await response.json(); + } + + public async createComparativeExperiment({ + name, + experimentIds, + referenceDatasetId, + createdAt, + description, + metadata, + id, + }: { + name: string; + experimentIds: Array; + referenceDatasetId?: string; + createdAt?: Date; + description?: string; + metadata?: Record; + id?: string; + }): Promise { + if (experimentIds.length === 0) { + throw new Error("At least one experiment is required"); + } + + if (!referenceDatasetId) { + referenceDatasetId = ( + await this.readProject({ + projectId: experimentIds[0], + }) + ).reference_dataset_id; + } + + if (!referenceDatasetId == null) { + throw new Error("A reference dataset is required"); + } + + const body = { + id, + name, + experiment_ids: experimentIds, + reference_dataset_id: referenceDatasetId, + description, + created_at: (createdAt ?? new Date())?.toISOString(), + extra: {} as Record, + }; + + if (metadata) body.extra["metadata"] = metadata; + + const serializedBody = JSON.stringify(body); + + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/datasets/comparative`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: serializedBody, + }); + await raiseForStatus(res, "create comparative experiment"); + return res; + }); + return response.json(); + } + + /** + * Retrieves a list of presigned feedback tokens for a given run ID. + * @param runId The ID of the run. + * @returns An async iterable of FeedbackIngestToken objects. + */ + public async *listPresignedFeedbackTokens( + runId: string, + ): AsyncIterable { + assertUuid(runId); + const params = new URLSearchParams({ run_id: runId }); + for await (const tokens of this._getPaginated( + "/feedback/tokens", + params, + )) { + yield* tokens; + } + } + + _selectEvalResults( + results: EvaluationResult | EvaluationResult[] | EvaluationResults, + ): Array { + let results_: Array; + if ("results" in results) { + results_ = results.results; + } else if (Array.isArray(results)) { + results_ = results; + } else { + results_ = [results]; + } + return results_; + } + + async _logEvaluationFeedback( + evaluatorResponse: + | EvaluationResult + | EvaluationResult[] + | EvaluationResults, + run?: Run, + sourceInfo?: { [key: string]: any }, + sessionId?: string, + ): Promise<[results: EvaluationResult[], feedbacks: Feedback[]]> { + const evalResults: Array = + this._selectEvalResults(evaluatorResponse); + + const feedbacks: Feedback[] = []; + + for (const res of evalResults) { + let sourceInfo_ = sourceInfo || {}; + if (res.evaluatorInfo) { + sourceInfo_ = { ...res.evaluatorInfo, ...sourceInfo_ }; + } + let runId_: string | null = null; + if (res.targetRunId) { + runId_ = res.targetRunId; + } else if (run) { + runId_ = run.id; + } + + feedbacks.push( + await this.createFeedback(runId_, res.key, { + score: res.score, + value: res.value, + comment: res.comment, + correction: res.correction, + sourceInfo: sourceInfo_, + sourceRunId: res.sourceRunId, + feedbackConfig: res.feedbackConfig as FeedbackConfig | undefined, + feedbackSourceType: "model", + sessionId: run?.session_id ?? sessionId, + startTime: run?.start_time, + // If an evaluator result targets a different run, we can't + // guarantee to know its trace ID. + traceId: runId_ === run?.id ? run?.trace_id : undefined, + }), + ); + } + + return [evalResults, feedbacks]; + } + + public async logEvaluationFeedback(params: { + evaluatorResponse: + | EvaluationResult + | EvaluationResult[] + | EvaluationResults; + run: Run; + projectId: string; + sourceInfo?: { [key: string]: any }; + }): Promise; + /** @deprecated Pass all params within an object and populate projectId. */ + public async logEvaluationFeedback( + evaluatorResponse: + | EvaluationResult + | EvaluationResult[] + | EvaluationResults, + run?: Run, + sourceInfo?: { [key: string]: any }, + sessionId?: string, + ): Promise; + public async logEvaluationFeedback( + evaluatorResponseOrParams: + | EvaluationResult + | EvaluationResult[] + | EvaluationResults + | { + evaluatorResponse: + | EvaluationResult + | EvaluationResult[] + | EvaluationResults; + run: Run; + projectId: string; + sourceInfo?: { [key: string]: any }; + }, + run?: Run, + sourceInfo?: { [key: string]: any }, + sessionId?: string, + ): Promise { + if ( + evaluatorResponseOrParams != null && + typeof evaluatorResponseOrParams === "object" && + "evaluatorResponse" in evaluatorResponseOrParams + ) { + const [results] = await this._logEvaluationFeedback( + evaluatorResponseOrParams.evaluatorResponse, + evaluatorResponseOrParams.run, + evaluatorResponseOrParams.sourceInfo, + evaluatorResponseOrParams.projectId, + ); + return results; + } + const [results] = await this._logEvaluationFeedback( + evaluatorResponseOrParams, + run, + sourceInfo, + sessionId, + ); + return results; + } + + /** + * API for managing feedback configs + */ + + /** + * Create a feedback configuration on the LangSmith API. + * + * This upserts: if an identical config already exists, it returns it. + * If a conflicting config exists for the same key, a 400 error is raised. + * + * @param options - The options for creating a feedback config + * @param options.feedbackKey - The unique key for this feedback config + * @param options.feedbackConfig - The config specifying type, bounds, and categories + * @param options.isLowerScoreBetter - Whether a lower score is better + * @returns The created FeedbackConfigSchema object + */ + public async createFeedbackConfig(options: { + feedbackKey: string; + feedbackConfig: FeedbackConfig; + isLowerScoreBetter?: boolean; + }): Promise { + const { feedbackKey, feedbackConfig, isLowerScoreBetter = false } = options; + const body = { + feedback_key: feedbackKey, + feedback_config: feedbackConfig, + is_lower_score_better: isLowerScoreBetter, + }; + + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/feedback-configs`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify(body), + }); + await raiseForStatus(res, "create feedback config"); + return res; + }); + return response.json(); + } + + /** + * List feedback configurations on the LangSmith API. + * @param options - The options for listing feedback configs + * @param options.feedbackKeys - Filter by specific feedback keys + * @param options.nameContains - Filter by name substring + * @param options.limit - The maximum number of configs to return + * @returns An async iterator of FeedbackConfigSchema objects + */ + public async *listFeedbackConfigs( + options: { + feedbackKeys?: string[]; + nameContains?: string; + limit?: number; + } = {}, + ): AsyncIterableIterator { + const { feedbackKeys, nameContains, limit } = options; + const params = new URLSearchParams(); + if (feedbackKeys) { + feedbackKeys.forEach((key) => { + params.append("key", key); + }); + } + if (nameContains) params.append("name_contains", nameContains); + params.append( + "limit", + (limit !== undefined ? Math.min(limit, 100) : 100).toString(), + ); + + let count = 0; + for await (const configs of this._getPaginated( + "/feedback-configs", + params, + )) { + yield* configs; + count += configs.length; + if (limit !== undefined && count >= limit) break; + } + } + + /** + * Update a feedback configuration on the LangSmith API. + * @param feedbackKey - The key of the feedback config to update + * @param options - The options for updating the feedback config + * @param options.feedbackConfig - The new feedback config + * @param options.isLowerScoreBetter - Whether a lower score is better + * @returns The updated FeedbackConfigSchema object + */ + public async updateFeedbackConfig( + feedbackKey: string, + options: { + feedbackConfig?: FeedbackConfig; + isLowerScoreBetter?: boolean; + } = {}, + ): Promise { + const { feedbackConfig, isLowerScoreBetter } = options; + const body: Record = { feedback_key: feedbackKey }; + if (feedbackConfig !== undefined) { + body.feedback_config = feedbackConfig; + } + if (isLowerScoreBetter !== undefined) { + body.is_lower_score_better = isLowerScoreBetter; + } + + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/feedback-configs`, { + method: "PATCH", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify(body), + }); + await raiseForStatus(res, "update feedback config"); + return res; + }); + return response.json(); + } + + /** + * Delete a feedback configuration on the LangSmith API. + * @param feedbackKey - The key of the feedback config to delete + */ + public async deleteFeedbackConfig(feedbackKey: string): Promise { + const params = new URLSearchParams({ feedback_key: feedbackKey }); + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/feedback-configs?${params}`, + { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete feedback config", true); + return res; + }); + } + + /** + * API for managing annotation queues + */ + + /** + * List the annotation queues on the LangSmith API. + * @param options - The options for listing annotation queues + * @param options.queueIds - The IDs of the queues to filter by + * @param options.name - The name of the queue to filter by + * @param options.nameContains - The substring that the queue name should contain + * @param options.limit - The maximum number of queues to return + * @returns An iterator of AnnotationQueue objects + */ + public async *listAnnotationQueues( + options: { + queueIds?: string[]; + name?: string; + nameContains?: string; + limit?: number; + } = {}, + ): AsyncIterableIterator { + const { queueIds, name, nameContains, limit } = options; + const params = new URLSearchParams(); + if (queueIds) { + queueIds.forEach((id, i) => { + assertUuid(id, `queueIds[${i}]`); + params.append("ids", id); + }); + } + if (name) params.append("name", name); + if (nameContains) params.append("name_contains", nameContains); + params.append( + "limit", + (limit !== undefined ? Math.min(limit, 100) : 100).toString(), + ); + + let count = 0; + for await (const queues of this._getPaginated( + "/annotation-queues", + params, + )) { + yield* queues; + count++; + if (limit !== undefined && count >= limit) break; + } + } + + /** + * Create an annotation queue on the LangSmith API. + * @param options - The options for creating an annotation queue + * @param options.name - The name of the annotation queue + * @param options.description - The description of the annotation queue + * @param options.queueId - The ID of the annotation queue + * @returns The created AnnotationQueue object + */ + public async createAnnotationQueue(options: { + name: string; + description?: string; + queueId?: string; + rubricInstructions?: string; + rubricItems?: AnnotationQueueRubricItem[]; + }): Promise { + const { name, description, queueId, rubricInstructions, rubricItems } = + options; + const body: Record = { + name, + description, + id: queueId || uuid.v4(), + rubric_instructions: rubricInstructions, + rubric_items: rubricItems, + }; + + const serializedBody = JSON.stringify( + Object.fromEntries( + Object.entries(body).filter(([_, v]) => v !== undefined), + ), + ); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/annotation-queues`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: serializedBody, + }); + await raiseForStatus(res, "create annotation queue"); + return res; + }); + return response.json(); + } + + /** + * Read an annotation queue with the specified queue ID. + * @param queueId - The ID of the annotation queue to read + * @returns The AnnotationQueueWithDetails object + */ + public async readAnnotationQueue( + queueId: string, + ): Promise { + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/annotation-queues/${assertUuid(queueId, "queueId")}`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "read annotation queue"); + return res; + }); + return response.json(); + } + + /** + * Update an annotation queue with the specified queue ID. + * @param queueId - The ID of the annotation queue to update + * @param options - The options for updating the annotation queue + * @param options.name - The new name for the annotation queue + * @param options.description - The new description for the annotation queue + */ + public async updateAnnotationQueue( + queueId: string, + options: { + name?: string; + description?: string; + rubricInstructions?: string; + rubricItems?: AnnotationQueueRubricItem[]; + }, + ): Promise { + const { name, description, rubricInstructions, rubricItems } = options; + const bodyObj: Record = {}; + if (name !== undefined) bodyObj.name = name; + if (description !== undefined) bodyObj.description = description; + if (rubricInstructions !== undefined) + bodyObj.rubric_instructions = rubricInstructions; + if (rubricItems !== undefined) bodyObj.rubric_items = rubricItems; + const body = JSON.stringify(bodyObj); + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/annotation-queues/${assertUuid(queueId, "queueId")}`, + { + method: "PATCH", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "update annotation queue", true); + return res; + }); + } + + /** + * Delete an annotation queue with the specified queue ID. + * @param queueId - The ID of the annotation queue to delete + */ + public async deleteAnnotationQueue(queueId: string): Promise { + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/annotation-queues/${assertUuid(queueId, "queueId")}`, + { + method: "DELETE", + headers: { ...this._mergedHeaders, Accept: "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete annotation queue", true); + return res; + }); + } + + /** + * Add runs to an annotation queue with the specified queue ID. + * + * The second argument is either: + * - `RunKey[]` (preferred): each entry carries the run's full lookup key, so + * it can be located directly without a scan. Required for workspaces served + * by SmithDB; routes to `POST /runs/by-key`. + * - `string[]`: a plain list of run IDs. **Deprecated**: this path will be + * removed after Jan 31, 2027; prefer the key form. Routes to `POST /runs`. + * See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#annotation-queues-add-runs. + * + * If every element is a string (or the list is empty) it is treated as run + * IDs; otherwise the list is treated as `RunKey` objects. + * + * @param queueId - The ID of the annotation queue + * @param runs - Either a list of run IDs (deprecated) or a list of run keys. + */ + public async addRunsToAnnotationQueue( + queueId: string, + runs: string[] | RunKey[], + ): Promise { + const base = `${this.apiUrl}/annotation-queues/${assertUuid( + queueId, + "queueId", + )}/runs`; + + // All strings (including empty) -> legacy /runs; otherwise treat as run keys. + const allStrings = runs.every((r) => typeof r === "string"); + let url: string; + let body: string; + if (!allStrings) { + url = `${base}/by-key`; + body = JSON.stringify( + (runs as RunKey[]).map((run, i) => { + const serialized: Record = { + run_id: assertUuid(run.runId, `runs[${i}].runId`).toString(), + session_id: assertUuid( + run.sessionId, + `runs[${i}].sessionId`, + ).toString(), + start_time: + typeof run.startTime === "string" + ? run.startTime + : new Date(run.startTime).toISOString(), + }; + if (run.sourceProposedExampleId != null) { + serialized.source_proposed_example_id = assertUuid( + run.sourceProposedExampleId, + `runs[${i}].sourceProposedExampleId`, + ).toString(); + } + return serialized; + }), + ); + } else { + warnOnce( + "Passing run IDs as strings to addRunsToAnnotationQueue() is deprecated and will be removed after Jan 31, 2027. " + + "Use RunKey[] instead. " + + "See https://docs.langchain.com/langsmith/smithdb-sdk-migration-feedback#annotation-queues-add-runs for the migration guide.", + { + type: "DeprecationWarning", + code: "LANGSMITH_DEPRECATED_ADD_RUNS_STRING_IDS", + }, + ); + url = base; + body = JSON.stringify( + (runs as string[]).map((id, i) => + assertUuid(id, `runs[${i}]`).toString(), + ), + ); + } + + await this.caller.call(async () => { + const res = await this._fetch(url, { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "add runs to annotation queue", true); + return res; + }); + } + + /** + * Get a run from an annotation queue at the specified index. + * @param queueId - The ID of the annotation queue + * @param index - The index of the run to retrieve + * @returns A Promise that resolves to a RunWithAnnotationQueueInfo object + * @throws {Error} If the run is not found at the given index or for other API-related errors + */ + public async getRunFromAnnotationQueue( + queueId: string, + index: number, + ): Promise { + const baseUrl = `/annotation-queues/${assertUuid(queueId, "queueId")}/run`; + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}${baseUrl}/${index}`, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "get run from annotation queue"); + return res; + }); + const run = await response.json(); + return _normalizeRunTimestamps(run); + } + + /** + * List the runs in an annotation queue. + * @param queueId - The ID of the annotation queue + * @param options - The options for listing runs in the annotation queue + * @param options.status - Filter runs by review status. If omitted, returns + * runs across all review states. + * @param options.limit - The maximum number of runs to return + * @returns An iterator of RunWithAnnotationQueueInfo objects + */ + public async *listRunsFromAnnotationQueue( + queueId: string, + options: { + status?: "needs_my_review" | "needs_others_review" | "completed"; + limit?: number; + } = {}, + ): AsyncIterableIterator { + const { status, limit: userLimit } = options; + const params = new URLSearchParams(); + const limit = + userLimit !== undefined && Number.isFinite(userLimit) + ? Math.min(userLimit, 100) + : 100; + + if (status) params.append("status", status); + params.append("limit", limit.toString()); + + let count = 0; + const path = `/annotation-queues/${assertUuid(queueId, "queueId")}/runs`; + for await (const runs of this._getPaginated( + path, + params, + )) { + for (const run of runs) { + yield _normalizeRunTimestamps(run); + count++; + if (count >= limit) return; + } + } + } + + /** + * Delete a run from an an annotation queue. + * @param queueId - The ID of the annotation queue to delete the run from + * @param queueRunId - The ID of the run to delete from the annotation queue + */ + public async deleteRunFromAnnotationQueue( + queueId: string, + queueRunId: string, + ): Promise { + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/annotation-queues/${assertUuid( + queueId, + "queueId", + )}/runs/${assertUuid(queueRunId, "queueRunId")}`, + { + method: "DELETE", + headers: { ...this._mergedHeaders, Accept: "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete run from annotation queue", true); + return res; + }); + } + + /** + * Get the size of an annotation queue. + * @param queueId - The ID of the annotation queue + */ + public async getSizeFromAnnotationQueue( + queueId: string, + ): Promise<{ size: number }> { + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/annotation-queues/${assertUuid( + queueId, + "queueId", + )}/size`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "get size from annotation queue"); + return res; + }); + return response.json(); + } + + protected async _currentTenantIsOwner(owner: string): Promise { + const settings = await this._getSettings(); + return owner == "-" || settings.tenant_handle === owner; + } + + protected async _ownerConflictError( + action: string, + owner: string, + ): Promise { + const settings = await this._getSettings(); + return new Error( + `Cannot ${action} for another tenant.\n + Current tenant: ${settings.tenant_handle}\n + Requested tenant: ${owner}`, + ); + } + + protected async _getLatestCommitHash( + promptOwnerAndName: string, + ): Promise { + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/commits/${promptOwnerAndName}/?limit=${1}&offset=${0}`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "get latest commit hash"); + return res; + }); + + const json = await response.json(); + if (json.commits.length === 0) { + return undefined; + } + + return json.commits[0].commit_hash; + } + + protected async _createCommitTags( + promptOwnerAndName: string, + commitId: string, + tags: string | string[], + ): Promise { + const tagList = typeof tags === "string" ? [tags] : tags; + + await Promise.all( + tagList.map(async (tag) => + this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/repos/${promptOwnerAndName}/tags`, + { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify({ tag_name: tag, commit_id: commitId }), + }, + ); + await raiseForStatus(res, "create commit tag"); + return res; + }), + ), + ); + } + + protected async _likeOrUnlikePrompt( + promptIdentifier: string, + like: boolean, + ): Promise { + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + const body = JSON.stringify({ like: like }); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/likes/${owner}/${promptName}`, + { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, `${like ? "like" : "unlike"} prompt`); + return res; + }); + return response.json(); + } + + protected async _getPromptUrl(promptIdentifier: string): Promise { + const [owner, promptName, commitHash] = + parseHubIdentifier(promptIdentifier); + if (!(await this._currentTenantIsOwner(owner))) { + if (commitHash !== "latest") { + return `${this.getHostUrl()}/hub/${owner}/${promptName}/${commitHash.substring( + 0, + 8, + )}`; + } else { + return `${this.getHostUrl()}/hub/${owner}/${promptName}`; + } + } else { + const settings = await this._getSettings(); + if (commitHash !== "latest") { + return `${this.getHostUrl()}/prompts/${promptName}/${commitHash.substring( + 0, + 8, + )}?organizationId=${settings.id}`; + } else { + return `${this.getHostUrl()}/prompts/${promptName}?organizationId=${ + settings.id + }`; + } + } + } + + /** + * Check if a prompt exists. + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * @returns A Promise that resolves to true if the prompt exists, false otherwise + * @example + * ```typescript + * // Check if a prompt exists before creating a commit + * if (await client.promptExists("my-prompt")) { + * await client.createCommit("my-prompt", template); + * } else { + * await client.createPrompt("my-prompt"); + * } + * ``` + */ + public async promptExists(promptIdentifier: string): Promise { + const prompt = await this.getPrompt(promptIdentifier); + return !!prompt; + } + + /** + * Like a prompt. + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * @returns A Promise that resolves to the like response containing the updated like count + * @example + * ```typescript + * // Like a prompt + * const response = await client.likePrompt("owner/useful-prompt"); + * console.log(`Prompt now has ${response.likes} likes`); + * ``` + */ + public async likePrompt( + promptIdentifier: string, + ): Promise { + return this._likeOrUnlikePrompt(promptIdentifier, true); + } + + /** + * Unlike a prompt (remove a previously added like). + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * @returns A Promise that resolves to the like response containing the updated like count + * @example + * ```typescript + * // Unlike a prompt + * const response = await client.unlikePrompt("owner/useful-prompt"); + * console.log(`Prompt now has ${response.likes} likes`); + * ``` + */ + public async unlikePrompt( + promptIdentifier: string, + ): Promise { + return this._likeOrUnlikePrompt(promptIdentifier, false); + } + + /** + * List all commits for a prompt. + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * - "promptName:commitHash" (commit hash is ignored, all commits are returned) + * @returns An async iterable iterator of PromptCommit objects + * @example + * ```typescript + * // List commits for a private prompt + * for await (const commit of client.listCommits("my-prompt")) { + * console.log(commit); + * } + * + * // List commits for a prompt with explicit owner + * for await (const commit of client.listCommits("owner/my-prompt")) { + * console.log(commit); + * } + * ``` + */ + public async *listCommits( + promptIdentifier: string, + ): AsyncIterableIterator { + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + for await (const commits of this._getPaginated< + PromptCommit, + ListCommitsResponse + >( + `/commits/${owner}/${promptName}/`, + new URLSearchParams(), + (res) => res.commits, + )) { + yield* commits; + } + } + + /** + * List prompts by filter. + * @param options - Optional filters for listing prompts + * @param options.isPublic - Filter by public/private prompts. If undefined, returns all prompts. + * @param options.isArchived - Filter by archived status. Defaults to false (non-archived prompts only). + * @param options.sortField - Field to sort by. Defaults to "updated_at". + * @param options.query - Search query to filter prompts by name or description. + * @returns An async iterable iterator of Prompt objects + * @example + * ```typescript + * // List all prompts + * for await (const prompt of client.listPrompts()) { + * console.log(prompt); + * } + * + * // List only public prompts + * for await (const prompt of client.listPrompts({ isPublic: true })) { + * console.log(prompt); + * } + * + * // Search for prompts + * for await (const prompt of client.listPrompts({ query: "translation" })) { + * console.log(prompt); + * } + * ``` + */ + public async *listPrompts(options?: { + isPublic?: boolean; + isArchived?: boolean; + sortField?: PromptSortField; + query?: string; + }): AsyncIterableIterator { + const params = new URLSearchParams(); + params.append("sort_field", options?.sortField ?? "updated_at"); + params.append("sort_direction", "desc"); + params.append("is_archived", (!!options?.isArchived).toString()); + + if (options?.isPublic !== undefined) { + params.append("is_public", options.isPublic.toString()); + } + + if (options?.query) { + params.append("query", options.query); + } + + for await (const prompts of this._getPaginated( + "/repos", + params, + (res) => res.repos, + )) { + yield* prompts; + } + } + + /** + * Get a prompt by its identifier. + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * - "promptName:commitHash" (commit hash is ignored, latest version is returned) + * @returns A Promise that resolves to the Prompt object, or null if not found + * @example + * ```typescript + * // Get a private prompt + * const prompt = await client.getPrompt("my-prompt"); + * + * // Get a public prompt + * const publicPrompt = await client.getPrompt("owner/public-prompt"); + * ``` + */ + public async getPrompt(promptIdentifier: string): Promise { + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/repos/${owner}/${promptName}`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + + if (res?.status === 404) { + return null; + } + await raiseForStatus(res, "get prompt"); + return res; + }); + const result = await response?.json(); + if (result?.repo) { + return result.repo as Prompt; + } else { + return null; + } + } + + /** + * Create a new prompt. + * @param promptIdentifier - The identifier for the new prompt. Can be in the format: + * - "promptName" (creates a private prompt) + * - "owner/promptName" (creates a prompt under a specific owner, must match your tenant) + * @param options - Optional configuration for the prompt + * @param options.description - A description of the prompt + * @param options.readme - Markdown content for the prompt's README + * @param options.tags - Array of tags to categorize the prompt + * @param options.isPublic - Whether the prompt should be public. Requires a LangChain Hub handle. + * @returns A Promise that resolves to the created Prompt object + * @throws {Error} If creating a public prompt without a LangChain Hub handle, or if owner doesn't match current tenant + * @example + * ```typescript + * // Create a private prompt + * const prompt = await client.createPrompt("my-new-prompt", { + * description: "A prompt for translations", + * tags: ["translation", "language"] + * }); + * + * // Create a public prompt + * const publicPrompt = await client.createPrompt("my-public-prompt", { + * description: "A public translation prompt", + * isPublic: true + * }); + * ``` + */ + public async createPrompt( + promptIdentifier: string, + options?: { + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + const settings = await this._getSettings(); + if (options?.isPublic && !settings.tenant_handle) { + throw new Error( + `Cannot create a public prompt without first\n + creating a LangChain Hub handle. + You can add a handle by creating a public prompt at:\n + https://smith.langchain.com/prompts`, + ); + } + + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + if (!(await this._currentTenantIsOwner(owner))) { + throw await this._ownerConflictError("create a prompt", owner); + } + + const data = { + repo_handle: promptName, + ...(options?.description && { description: options.description }), + ...(options?.readme && { readme: options.readme }), + ...(options?.tags && { tags: options.tags }), + is_public: !!options?.isPublic, + }; + + const body = JSON.stringify(data); + const response = await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/repos/`, { + method: "POST", + headers: { ...this._mergedHeaders, "Content-Type": "application/json" }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }); + await raiseForStatus(res, "create prompt"); + return res; + }); + const { repo } = await response.json(); + return repo as Prompt; + } + + /** + * Create a new commit for an existing prompt. + * @param promptIdentifier - The identifier of the prompt. Can be in the format: + * - "promptName" (for private prompts, owner defaults to "-") + * - "owner/promptName" (for prompts with explicit owner) + * @param object - The prompt object/manifest to commit (e.g., ChatPromptTemplate, messages array, etc.) + * @param options - Optional configuration for the commit + * @param options.parentCommitHash - The parent commit hash. Defaults to "latest" (the most recent commit). + * @param options.tags - A tag or list of tags to apply to the commit. + * @param options.description - A description for the commit. + * @returns A Promise that resolves to the URL of the newly created commit + * @throws {Error} If the prompt does not exist + * @example + * ```typescript + * import { ChatPromptTemplate } from "@langchain/core/prompts"; + * + * // Create a commit with a new version of the prompt + * const template = ChatPromptTemplate.fromMessages([ + * ["system", "You are a helpful assistant."], + * ["human", "{input}"] + * ]); + * + * const commitUrl = await client.createCommit("my-prompt", template); + * console.log(`Commit created: ${commitUrl}`); + * + * // Create a commit with tags + * const commitUrl2 = await client.createCommit("my-prompt", template, { + * tags: ["production", "v1"] + * }); + * ``` + */ + public async createCommit( + promptIdentifier: string, + object: any, + options?: { + parentCommitHash?: string; + tags?: string | string[]; + description?: string; + }, + ): Promise { + if (!(await this.promptExists(promptIdentifier))) { + throw new Error("Prompt does not exist, you must create it first."); + } + + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + const resolvedParentCommitHash = + options?.parentCommitHash === "latest" || !options?.parentCommitHash + ? await this._getLatestCommitHash(`${owner}/${promptName}`) + : options?.parentCommitHash; + + const payload = { + manifest: JSON.parse(JSON.stringify(object)), + parent_commit: resolvedParentCommitHash, + ...(options?.description !== undefined && { + description: options.description, + }), + }; + + const body = JSON.stringify(payload); + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/commits/${owner}/${promptName}`, + { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "create commit"); + return res; + }); + const result = await response.json(); + const commit = result.commit ?? result; + if (options?.tags) { + await this._createCommitTags( + `${owner}/${promptName}`, + commit.id, + options.tags, + ); + } + return this._getPromptUrl( + `${owner}/${promptName}${ + commit.commit_hash ? `:${commit.commit_hash}` : "" + }`, + ); + } + + /** + * Update examples with attachments using multipart form data. + * @param updates List of ExampleUpdateWithAttachments objects to upsert + * @returns Promise with the update response + */ + public async updateExamplesMultipart( + datasetId: string, + updates: ExampleUpdate[] = [], + ): Promise { + return this._updateExamplesMultipart(datasetId, updates); + } + + private async _updateExamplesMultipart( + datasetId: string, + updates: ExampleUpdate[] = [], + ): Promise { + if (!(await this._getDatasetExamplesMultiPartSupport())) { + throw new Error( + "Your LangSmith deployment does not allow using the multipart examples endpoint, please upgrade your deployment to the latest version.", + ); + } + const formData = new FormData(); + + for (const example of updates) { + const exampleId = example.id; + + // Prepare the main example body + const exampleBody = { + ...(example.metadata && { metadata: example.metadata }), + ...(example.split && { split: example.split }), + }; + + // Add main example data + const stringifiedExample = serializePayloadForTracing( + exampleBody, + `Serializing body for example with id: ${exampleId}`, + ); + const exampleBlob = new Blob([stringifiedExample], { + type: "application/json", + }); + formData.append(exampleId, exampleBlob); + + // Add inputs if present + if (example.inputs) { + const stringifiedInputs = serializePayloadForTracing( + example.inputs, + `Serializing inputs for example with id: ${exampleId}`, + ); + const inputsBlob = new Blob([stringifiedInputs], { + type: "application/json", + }); + formData.append(`${exampleId}.inputs`, inputsBlob); + } + + // Add outputs if present + if (example.outputs) { + const stringifiedOutputs = serializePayloadForTracing( + example.outputs, + `Serializing outputs whle updating example with id: ${exampleId}`, + ); + const outputsBlob = new Blob([stringifiedOutputs], { + type: "application/json", + }); + formData.append(`${exampleId}.outputs`, outputsBlob); + } + + // Add attachments if present + if (example.attachments) { + for (const [name, attachment] of Object.entries(example.attachments)) { + let mimeType: string; + let data: AttachmentData; + + if (Array.isArray(attachment)) { + [mimeType, data] = attachment; + } else { + mimeType = attachment.mimeType; + data = attachment.data; + } + const attachmentBlob = new Blob([data as BlobPart], { + type: `${mimeType}; length=${data.byteLength}`, + }); + formData.append(`${exampleId}.attachment.${name}`, attachmentBlob); + } + } + + if (example.attachments_operations) { + const stringifiedAttachmentsOperations = serializePayloadForTracing( + example.attachments_operations, + `Serializing attachments while updating example with id: ${exampleId}`, + ); + const attachmentsOperationsBlob = new Blob( + [stringifiedAttachmentsOperations], + { + type: "application/json", + }, + ); + formData.append( + `${exampleId}.attachments_operations`, + attachmentsOperationsBlob, + ); + } + } + + const datasetIdToUse = datasetId ?? updates[0]?.dataset_id; + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}${this._getPlatformEndpointPath( + `datasets/${datasetIdToUse}/examples`, + )}`, + { + method: "PATCH", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: formData, + }, + ); + await raiseForStatus(res, "update examples"); + return res; + }); + return response.json(); + } + + /** + * Upload examples with attachments using multipart form data. + * @param uploads List of ExampleUploadWithAttachments objects to upload + * @returns Promise with the upload response + * @deprecated This method is deprecated and will be removed in future LangSmith versions, please use `createExamples` instead + */ + public async uploadExamplesMultipart( + datasetId: string, + uploads: ExampleCreate[] = [], + ): Promise { + return this._uploadExamplesMultipart(datasetId, uploads); + } + + private async _uploadExamplesMultipart( + datasetId: string, + uploads: ExampleCreate[] = [], + ): Promise { + if (!(await this._getDatasetExamplesMultiPartSupport())) { + throw new Error( + "Your LangSmith deployment does not allow using the multipart examples endpoint, please upgrade your deployment to the latest version.", + ); + } + const formData = new FormData(); + + for (const example of uploads) { + const exampleId = (example.id ?? uuid.v4()).toString(); + + // Prepare the main example body + const exampleBody = { + created_at: example.created_at, + ...(example.metadata && { metadata: example.metadata }), + ...(example.split && { split: example.split }), + ...(example.source_run_id && { source_run_id: example.source_run_id }), + ...(example.use_source_run_io && { + use_source_run_io: example.use_source_run_io, + }), + ...(example.use_source_run_attachments && { + use_source_run_attachments: example.use_source_run_attachments, + }), + }; + + // Add main example data + const stringifiedExample = serializePayloadForTracing( + exampleBody, + `Serializing body for uploaded example with id: ${exampleId}`, + ); + const exampleBlob = new Blob([stringifiedExample], { + type: "application/json", + }); + formData.append(exampleId, exampleBlob); + + // Add inputs if present + if (example.inputs) { + const stringifiedInputs = serializePayloadForTracing( + example.inputs, + `Serializing inputs for uploaded example with id: ${exampleId}`, + ); + const inputsBlob = new Blob([stringifiedInputs], { + type: "application/json", + }); + formData.append(`${exampleId}.inputs`, inputsBlob); + } + + // Add outputs if present + if (example.outputs) { + const stringifiedOutputs = serializePayloadForTracing( + example.outputs, + `Serializing outputs for uploaded example with id: ${exampleId}`, + ); + const outputsBlob = new Blob([stringifiedOutputs], { + type: "application/json", + }); + formData.append(`${exampleId}.outputs`, outputsBlob); + } + + // Add attachments if present + if (example.attachments) { + for (const [name, attachment] of Object.entries(example.attachments)) { + let mimeType: string; + let data: AttachmentData; + + if (Array.isArray(attachment)) { + [mimeType, data] = attachment; + } else { + mimeType = attachment.mimeType; + data = attachment.data; + } + const attachmentBlob = new Blob([data as BlobPart], { + type: `${mimeType}; length=${data.byteLength}`, + }); + formData.append(`${exampleId}.attachment.${name}`, attachmentBlob); + } + } + } + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}${this._getPlatformEndpointPath( + `datasets/${datasetId}/examples`, + )}`, + { + method: "POST", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: formData, + }, + ); + await raiseForStatus(res, "upload examples"); + return res; + }); + return response.json(); + } + + public async updatePrompt( + promptIdentifier: string, + options?: { + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + isArchived?: boolean; + }, + ): Promise> { + if (!(await this.promptExists(promptIdentifier))) { + throw new Error("Prompt does not exist, you must create it first."); + } + + const [owner, promptName] = parseHubIdentifier(promptIdentifier); + if (!(await this._currentTenantIsOwner(owner))) { + throw await this._ownerConflictError("update a prompt", owner); + } + + const payload: Record = {}; + + if (options?.description !== undefined) + payload.description = options.description; + if (options?.readme !== undefined) payload.readme = options.readme; + if (options?.tags !== undefined) payload.tags = options.tags; + if (options?.isPublic !== undefined) payload.is_public = options.isPublic; + if (options?.isArchived !== undefined) + payload.is_archived = options.isArchived; + + // Check if payload is empty + if (Object.keys(payload).length === 0) { + throw new Error("No valid update options provided"); + } + + const body = JSON.stringify(payload); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/repos/${owner}/${promptName}`, + { + method: "PATCH", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body, + }, + ); + await raiseForStatus(res, "update prompt"); + return res; + }); + return response.json(); + } + + public async deletePrompt(promptIdentifier: string): Promise { + if (!(await this.promptExists(promptIdentifier))) { + throw new Error("Prompt does not exist, you must create it first."); + } + + const [owner, promptName, _] = parseHubIdentifier(promptIdentifier); + + if (!(await this._currentTenantIsOwner(owner))) { + throw await this._ownerConflictError("delete a prompt", owner); + } + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/repos/${owner}/${promptName}`, + { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete prompt"); + return res; + }); + return response.json(); + } + + /** + * Generate a cache key for a prompt. + * Format: "{identifier}" or "{identifier}:with_model" + */ + private _getPromptCacheKey( + promptIdentifier: string, + includeModel?: boolean, + ): string { + const suffix = includeModel ? ":with_model" : ""; + return `${promptIdentifier}${suffix}`; + } + + /** + * Fetch a prompt commit directly from the API (bypassing cache). + */ + private async _fetchPromptFromApi( + promptIdentifier: string, + options?: { includeModel?: boolean }, + ): Promise { + const [owner, promptName, commitHash] = + parseHubIdentifier(promptIdentifier); + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}/commits/${owner}/${promptName}/${commitHash}${ + options?.includeModel ? "?include_model=true" : "" + }`, + { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "pull prompt commit"); + return res; + }); + + const result = await response.json(); + + return { + owner, + repo: promptName, + commit_hash: result.commit_hash, + manifest: result.manifest, + examples: result.examples, + hub_model_config: result.model_config, + hub_model_provider: result.model_provider, + }; + } + + /** + * Pull a prompt commit from the LangSmith API. + * + * Public prompts referenced by owner/name cross a trust boundary because the + * prompt manifest may contain serialized LangChain objects and configuration + * that affect runtime behavior. For example, a prompt can intentionally + * configure a model with a custom base URL, headers, model name, or other + * constructor arguments. These are supported features, but they also mean the + * prompt contents should be treated as executable configuration rather than + * plain text. + * + * Set `dangerouslyPullPublicPrompt: true` only after reviewing and trusting + * the prompt contents, not merely the publishing account. Prompts from your + * own or your organization's account can still be unsafe if that account or + * prompt was compromised. + * + * When pulling a trusted external prompt, prefer pinning to a specific commit + * rather than following a mutable latest version. Using `includeModel: true` + * increases risk and should be avoided for public prompts or prompts outside + * your own organization. + */ + public async pullPromptCommit( + promptIdentifier: string, + options?: { + includeModel?: boolean; + skipCache?: boolean; + /** + * Set to `true` to allow pulling a public prompt by owner/name, for + * example `username/promptname`. Defaults to `false`. + */ + dangerouslyPullPublicPrompt?: boolean; + }, + ): Promise { + assertPullPublicPromptAllowed( + promptIdentifier, + options?.dangerouslyPullPublicPrompt, + ); + + // Check cache first if not skipped + const refreshFunc = this._fetchPromptFromApi.bind( + this, + promptIdentifier, + options, + ); + if (!options?.skipCache && this._promptCache) { + const cacheKey = this._getPromptCacheKey( + promptIdentifier, + options?.includeModel, + ); + const cached = this._promptCache.get(cacheKey, refreshFunc); + if (cached) { + return cached; + } + + // Cache miss - fetch from API and cache it + const result = await refreshFunc(); + this._promptCache.set(cacheKey, result, refreshFunc); + return result; + } + + // No cache or skip cache - fetch directly + return this._fetchPromptFromApi(promptIdentifier, options); + } + + /** + * This method should not be used directly, use `import { pull } from "langchain/hub"` instead. + * Using this method directly returns the JSON string of the prompt rather than a LangChain object. + * + * Public prompts referenced by owner/name cross a trust boundary because the + * prompt manifest may contain serialized LangChain objects and configuration + * that affect runtime behavior. For example, a prompt can intentionally + * configure a model with a custom base URL, headers, model name, or other + * constructor arguments. These are supported features, but they also mean the + * prompt contents should be treated as executable configuration rather than + * plain text. + * + * Set `dangerouslyPullPublicPrompt: true` only after reviewing and trusting + * the prompt contents, not merely the publishing account. Prompts from your + * own or your organization's account can still be unsafe if that account or + * prompt was compromised. + * + * When pulling a trusted external prompt, prefer pinning to a specific commit + * rather than following a mutable latest version. Using `includeModel: true` + * increases risk and should be avoided for public prompts or prompts outside + * your own organization. + * @private + */ + public async _pullPrompt( + promptIdentifier: string, + options?: { + includeModel?: boolean; + skipCache?: boolean; + /** + * Set to `true` to allow pulling a public prompt by owner/name, for + * example `username/promptname`. Defaults to `false`. + */ + dangerouslyPullPublicPrompt?: boolean; + }, + ): Promise { + const promptObject = await this.pullPromptCommit(promptIdentifier, { + includeModel: options?.includeModel, + skipCache: options?.skipCache, + dangerouslyPullPublicPrompt: options?.dangerouslyPullPublicPrompt, + }); + const prompt = JSON.stringify(promptObject.manifest); + return prompt; + } + + public async pushPrompt( + promptIdentifier: string, + options?: { + object?: any; + parentCommitHash?: string; + isPublic?: boolean; + description?: string; + readme?: string; + tags?: string[]; + commitTags?: string | string[]; + commitDescription?: string; + }, + ): Promise { + // Create or update prompt metadata + if (await this.promptExists(promptIdentifier)) { + if ( + options && + ["description", "readme", "tags", "isPublic"].some( + (key) => options[key as keyof typeof options] !== undefined, + ) + ) { + await this.updatePrompt(promptIdentifier, { + description: options?.description, + readme: options?.readme, + tags: options?.tags, + isPublic: options?.isPublic, + }); + } + } else { + await this.createPrompt(promptIdentifier, { + description: options?.description, + readme: options?.readme, + tags: options?.tags, + isPublic: options?.isPublic, + }); + } + + if (!options?.object) { + return await this._getPromptUrl(promptIdentifier); + } + + // Create a commit with the new manifest + const url = await this.createCommit(promptIdentifier, options?.object, { + parentCommitHash: options?.parentCommitHash, + tags: options?.commitTags, + description: options?.commitDescription, + }); + return url; + } + + /** + * Check if an agent repo exists. + */ + public async agentExists(identifier: string): Promise { + const [owner, name] = parseHubIdentifier(identifier); + return this._repoExists(owner, name); + } + + /** + * Check if a skill repo exists. + */ + public async skillExists(identifier: string): Promise { + const [owner, name] = parseHubIdentifier(identifier); + return this._repoExists(owner, name); + } + + /** + * Pull an agent directory from Hub. + * @param identifier The identifier (owner/name[:version]). + * @param options.version Commit hash or tag; overrides identifier's version. + */ + public async pullAgent( + identifier: string, + options?: { version?: string }, + ): Promise { + return (await this._pullDirectory( + identifier, + "agent", + options?.version, + )) as AgentContext; + } + + /** + * Pull a skill directory from Hub. + */ + public async pullSkill( + identifier: string, + options?: { version?: string }, + ): Promise { + return (await this._pullDirectory( + identifier, + "skill", + options?.version, + )) as SkillContext; + } + + /** + * Push an agent to Hub. Creates the repo if missing, patches metadata if + * provided, then commits the given files. + * @returns The URL of the resulting commit. + */ + public async pushAgent( + identifier: string, + options: { + files: Record; + parentCommit?: string; + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + return this._pushDirectory(identifier, "agent", options); + } + + /** + * Push a skill to Hub. + */ + public async pushSkill( + identifier: string, + options: { + files: Record; + parentCommit?: string; + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + return this._pushDirectory(identifier, "skill", options); + } + + /** + * Delete an agent and all its owned child file repos. + */ + public async deleteAgent(identifier: string): Promise { + return this._deleteDirectory(identifier); + } + + /** + * Delete a skill and all its owned child file repos. + */ + public async deleteSkill(identifier: string): Promise { + return this._deleteDirectory(identifier); + } + + /** + * List agent repos. Yields one at a time, auto-paginating. + */ + public async *listAgents(options?: { + isPublic?: boolean; + isArchived?: boolean; + query?: string; + }): AsyncIterableIterator { + yield* this._listReposByType("agent", options); + } + + /** + * List skill repos. Yields one at a time, auto-paginating. + */ + public async *listSkills(options?: { + isPublic?: boolean; + isArchived?: boolean; + query?: string; + }): AsyncIterableIterator { + yield* this._listReposByType("skill", options); + } + + private async *_listReposByType( + repoType: HubRepoType, + options?: { isPublic?: boolean; isArchived?: boolean; query?: string }, + ): AsyncIterableIterator { + const params = new URLSearchParams(); + params.append("repo_type", repoType); + params.append("is_archived", (!!options?.isArchived).toString()); + if (options?.isPublic !== undefined) { + params.append("is_public", options.isPublic.toString()); + } + if (options?.query) { + params.append("query", options.query); + } + + for await (const repos of this._getPaginated( + "/repos", + params, + (res) => res.repos, + )) { + yield* repos; + } + } + + private async _pullDirectory( + identifier: string, + repoType: HubRepoType, + version?: string, + ): Promise { + const [owner, name, parsedVersion] = parseHubIdentifier(identifier); + const resolvedVersion = + version ?? (parsedVersion !== "latest" ? parsedVersion : undefined); + + const url = new URL( + `${this.apiUrl}${this._getPlatformEndpointPath( + `hub/repos/${owner}/${name}/directories`, + )}`, + ); + url.searchParams.set("repo_type", repoType); + if (resolvedVersion) { + url.searchParams.set("commit", resolvedVersion); + } + + const response = await this.caller.call(async () => { + const res = await this._fetch(url.toString(), { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "pull directory"); + return res; + }); + return (await response.json()) as AgentContext | SkillContext; + } + + private async _pushDirectory( + identifier: string, + repoType: HubRepoType, + options: { + files: Record; + parentCommit?: string; + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + if ( + options.parentCommit !== undefined && + (options.parentCommit.length < 8 || options.parentCommit.length > 64) + ) { + throw new Error("parent_commit must be 8-64 characters"); + } + + const [owner, name] = parseHubIdentifier(identifier); + if (!(await this._currentTenantIsOwner(owner))) { + throw await this._ownerConflictError(`push ${repoType}`, owner); + } + + if (await this._repoExists(owner, name)) { + if ( + options.description !== undefined || + options.readme !== undefined || + options.tags !== undefined || + options.isPublic !== undefined + ) { + await this._updateRepoMetadata(owner, name, options); + } + } else { + const REPO_HANDLE_PATTERN = /^[a-z][a-z0-9-_]*$/; + if (!REPO_HANDLE_PATTERN.test(name)) { + throw new Error( + `Invalid repo_handle ${JSON.stringify( + name, + )}: must match ${REPO_HANDLE_PATTERN}`, + ); + } + await this._createRepo(name, repoType, options); + } + + const body: Record = { files: options.files }; + if (options.parentCommit) { + body.parent_commit = options.parentCommit; + } + + const response = await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}${this._getPlatformEndpointPath( + `hub/repos/${owner}/${name}/directories/commits`, + )}`, + { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify(body), + }, + ); + await raiseForStatus(res, `push ${repoType}`); + return res; + }); + const data = (await response.json()) as DirectoryCommitResponse; + const commitHash = data.commit.commit_hash; + const settings = await this._getSettings(); + const query = new URLSearchParams({ organizationId: settings.id }); + return `${this.getHostUrl()}/context/${name}/${commitHash.slice( + 0, + 8, + )}?${query.toString()}`; + } + + private async _deleteDirectory(identifier: string): Promise { + const [owner, name] = parseHubIdentifier(identifier); + if (!(await this._currentTenantIsOwner(owner))) { + throw await this._ownerConflictError("delete", owner); + } + await this.caller.call(async () => { + const res = await this._fetch( + `${this.apiUrl}${this._getPlatformEndpointPath( + `hub/repos/${owner}/${name}/directories`, + )}`, + { + method: "DELETE", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }, + ); + await raiseForStatus(res, "delete directory"); + return res; + }); + } + + private async _repoExists(owner: string, name: string): Promise { + try { + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/repos/${owner}/${name}`, { + method: "GET", + headers: this._mergedHeaders, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + }); + await raiseForStatus(res, "check repo exists"); + return res; + }); + return true; + } catch (e) { + if (isLangSmithNotFoundError(e)) { + return false; + } + throw e; + } + } + + private async _createRepo( + name: string, + repoType: HubRepoType, + options: { + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + const body: Record = { + repo_handle: name, + repo_type: repoType, + is_public: !!options.isPublic, + }; + if (options.description !== undefined) + body.description = options.description; + if (options.readme !== undefined) body.readme = options.readme; + if (options.tags !== undefined) body.tags = options.tags; + + try { + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/repos/`, { + method: "POST", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify(body), + }); + await raiseForStatus(res, `create ${repoType}`); + return res; + }); + } catch (e) { + if (isLangSmithConflictError(e)) { + return; + } + throw e; + } + } + + private async _updateRepoMetadata( + owner: string, + name: string, + options: { + description?: string; + readme?: string; + tags?: string[]; + isPublic?: boolean; + }, + ): Promise { + const body: Record = {}; + if (options.description !== undefined) + body.description = options.description; + if (options.readme !== undefined) body.readme = options.readme; + if (options.tags !== undefined) body.tags = options.tags; + if (options.isPublic !== undefined) body.is_public = options.isPublic; + if (Object.keys(body).length === 0) return; + + await this.caller.call(async () => { + const res = await this._fetch(`${this.apiUrl}/repos/${owner}/${name}`, { + method: "PATCH", + headers: { + ...this._mergedHeaders, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(this.timeout_ms), + ...this.fetchOptions, + body: JSON.stringify(body), + }); + await raiseForStatus(res, "update repo metadata"); + return res; + }); + } + + /** + * Clone a public dataset to your own langsmith tenant. + * This operation is idempotent. If you already have a dataset with the given name, + * this function will do nothing. + + * @param {string} tokenOrUrl The token of the public dataset to clone. + * @param {Object} [options] Additional options for cloning the dataset. + * @param {string} [options.sourceApiUrl] The URL of the langsmith server where the data is hosted. Defaults to the API URL of your current client. + * @param {string} [options.datasetName] The name of the dataset to create in your tenant. Defaults to the name of the public dataset. + * @returns {Promise} + */ + async clonePublicDataset( + tokenOrUrl: string, + options: { + sourceApiUrl?: string; + datasetName?: string; + } = {}, + ): Promise { + const { sourceApiUrl = this.apiUrl, datasetName } = options; + const [parsedApiUrl, tokenUuid] = this.parseTokenOrUrl( + tokenOrUrl, + sourceApiUrl, + ); + const sourceClient = new Client({ + apiUrl: parsedApiUrl, + // Placeholder API key not needed anymore in most cases, but + // some private deployments may have API key-based rate limiting + // that would cause this to fail if we provide no value. + apiKey: "placeholder", + }); + + const ds = await sourceClient.readSharedDataset(tokenUuid); + const finalDatasetName = datasetName || ds.name; + + try { + if (await this.hasDataset({ datasetId: finalDatasetName })) { + console.log( + `Dataset ${finalDatasetName} already exists in your tenant. Skipping.`, + ); + return; + } + } catch (_) { + // `.hasDataset` will throw an error if the dataset does not exist. + // no-op in that case + } + + // Fetch examples first, then create the dataset + const examples = await sourceClient.listSharedExamples(tokenUuid); + const dataset = await this.createDataset(finalDatasetName, { + description: ds.description, + dataType: ds.data_type || "kv", + inputsSchema: ds.inputs_schema_definition ?? undefined, + outputsSchema: ds.outputs_schema_definition ?? undefined, + }); + try { + await this.createExamples({ + inputs: examples.map((e) => e.inputs), + outputs: examples.flatMap((e) => (e.outputs ? [e.outputs] : [])), + datasetId: dataset.id, + }); + } catch (e) { + console.error( + `An error occurred while creating dataset ${finalDatasetName}. ` + + "You should delete it manually.", + ); + throw e; + } + } + + private parseTokenOrUrl( + urlOrToken: string, + apiUrl: string, + numParts = 2, + kind = "dataset", + ): [string, string] { + // Try parsing as UUID + try { + assertUuid(urlOrToken); // Will throw if it's not a UUID. + return [apiUrl, urlOrToken]; + } catch (_) { + // no-op if it's not a uuid + } + + // Parse as URL + try { + const parsedUrl = new URL(urlOrToken); + const pathParts = parsedUrl.pathname + .split("/") + .filter((part) => part !== ""); + + if (pathParts.length >= numParts) { + const tokenUuid = pathParts[pathParts.length - numParts]; + return [apiUrl, tokenUuid]; + } else { + throw new Error(`Invalid public ${kind} URL: ${urlOrToken}`); + } + } catch (_error) { + throw new Error(`Invalid public ${kind} URL or token: ${urlOrToken}`); + } + } + + /** + * Cleanup resources held by the client. + * Stops the cache's background refresh timer. + */ + public cleanup(): void { + if (this._promptCache) { + this._promptCache.stop(); + } + } + + /** + * Awaits all pending trace batches. Useful for environments where + * you need to be sure that all tracing requests finish before execution ends, + * such as serverless environments. + * + * @example + * ``` + * import { Client } from "langsmith"; + * + * const client = new Client(); + * + * try { + * // Tracing happens here + * ... + * } finally { + * await client.awaitPendingTraceBatches(); + * } + * ``` + * + * @returns A promise that resolves once all currently pending traces have sent. + */ + public async awaitPendingTraceBatches() { + if (this.manualFlushMode) { + console.warn( + "[WARNING]: When tracing in manual flush mode, you must call `await client.flush()` manually to submit trace batches.", + ); + return Promise.resolve(); + } + /** + * traceables use a backgrounded promise before updating runs to avoid blocking + * and to allow waiting for child runs to end. Waiting a small amount of time + * here ensures that they are able to enqueue their run operation before we await + * queued run operations below: + * + * ```ts + * const run = await traceable(async () => { + * return "Hello, world!"; + * }, { client })(); + * + * await client.awaitPendingTraceBatches(); + * ``` + */ + await new Promise((resolve) => setTimeout(resolve, 1)); + // Wait for any in-flight drains (whose serialize work may still be + // in-progress on a worker thread and not yet registered with the + // batchIngestCaller queue) before checking queue idleness. + while (this._pendingDrains.size > 0) { + await Promise.all([...this._pendingDrains]); + } + await Promise.all([ + ...this.autoBatchQueue.items.map(({ itemPromise }) => itemPromise), + this.batchIngestCaller.queue.onIdle(), + ]); + if (this.langSmithToOTELTranslator !== undefined) { + await getDefaultOTLPTracerComponents()?.DEFAULT_LANGSMITH_SPAN_PROCESSOR?.forceFlush(); + } + } + + /** + * Returns a string representation of the Client instance. + * This method is called when the object is converted to a string + * or logged, ensuring sensitive information like API keys is not exposed. + * + * @returns A string representation of the Client. + */ + public toString(): string { + const params = [`apiUrl=${JSON.stringify(this.apiUrl)}`]; + if (this.webUrl !== undefined) { + params.push(`webUrl=${JSON.stringify(this.webUrl)}`); + } + if (this.workspaceId !== undefined) { + params.push(`workspaceId=${JSON.stringify(this.workspaceId)}`); + } + return `[LangSmithClient ${params.join(" ")}]`; + } + + /** + * Custom inspect method for Node.js. + * This method is called when the object is inspected in the Node.js REPL + * or with console.log, ensuring sensitive information like API keys is not exposed. + * + * @returns A string representation of the Client for inspection. + */ + public [Symbol.for("nodejs.util.inspect.custom")](): string { + return this.toString(); + } +} + +export interface LangSmithTracingClientInterface { + createRun: (run: CreateRunParams) => Promise; + + updateRun: (runId: string, run: RunUpdate) => Promise; +} + +function isExampleCreate(input: KVMap | ExampleCreate): input is ExampleCreate { + return "dataset_id" in input || "dataset_name" in input; +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..fb7b23a --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,24 @@ +import { getLangSmithEnvironmentVariable } from "./utils/env.js"; + +export const isEnvTracingEnabled = (tracingEnabled?: boolean): boolean => { + if (tracingEnabled !== undefined) { + return tracingEnabled; + } + const envVars = ["TRACING_V2", "TRACING"]; + return !!envVars.find( + (envVar) => getLangSmithEnvironmentVariable(envVar) === "true", + ); +}; + +/** + * Whether a failed redactor should fall back to tracing the raw payload. + * + * Set `LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS` to restore the pre-fail-closed + * behavior in an emergency. It uploads data no redactor processed, so it is off + * by default and the caller logs a warning naming it whenever it takes effect. + * Read only on failure, so it stays off the hot path. + */ +export const allowUnprocessedPayloads = (): boolean => { + const value = getLangSmithEnvironmentVariable("ALLOW_UNPROCESSED_PAYLOADS"); + return value?.toLowerCase() === "true" || value === "1"; +}; diff --git a/src/lib/evaluation/_random_name.ts b/src/lib/evaluation/_random_name.ts new file mode 100644 index 0000000..aa2c833 --- /dev/null +++ b/src/lib/evaluation/_random_name.ts @@ -0,0 +1,728 @@ +const adjectives = [ + "abandoned", + "aching", + "advanced", + "ample", + "artistic", + "back", + "best", + "bold", + "brief", + "clear", + "cold", + "complicated", + "cooked", + "crazy", + "crushing", + "damp", + "dear", + "definite", + "dependable", + "diligent", + "drab", + "earnest", + "elderly", + "enchanted", + "essential", + "excellent", + "extraneous", + "fixed", + "flowery", + "formal", + "fresh", + "frosty", + "giving", + "glossy", + "healthy", + "helpful", + "impressionable", + "kind", + "large", + "left", + "long", + "loyal", + "mealy", + "memorable", + "monthly", + "new", + "notable", + "only", + "ordinary", + "passionate", + "perfect", + "pertinent", + "proper", + "puzzled", + "reflecting", + "respectful", + "roasted", + "scholarly", + "shiny", + "slight", + "sparkling", + "spotless", + "stupendous", + "sunny", + "tart", + "terrific", + "timely", + "unique", + "upbeat", + "vacant", + "virtual", + "warm", + "weary", + "whispered", + "worthwhile", + "yellow", +]; + +const nouns = [ + "account", + "acknowledgment", + "address", + "advertising", + "airplane", + "animal", + "appointment", + "arrival", + "artist", + "attachment", + "attitude", + "availability", + "backpack", + "bag", + "balance", + "bass", + "bean", + "beauty", + "bibliography", + "bill", + "bite", + "blossom", + "boat", + "book", + "box", + "boy", + "bread", + "bridge", + "broccoli", + "building", + "butter", + "button", + "cabbage", + "cake", + "camera", + "camp", + "candle", + "candy", + "canvas", + "car", + "card", + "carrot", + "cart", + "case", + "cat", + "chain", + "chair", + "chalk", + "chance", + "change", + "channel", + "character", + "charge", + "charm", + "chart", + "check", + "cheek", + "cheese", + "chef", + "cherry", + "chicken", + "child", + "church", + "circle", + "class", + "clay", + "click", + "clock", + "cloth", + "cloud", + "clove", + "club", + "coach", + "coal", + "coast", + "coat", + "cod", + "coffee", + "collar", + "color", + "comb", + "comfort", + "comic", + "committee", + "community", + "company", + "comparison", + "competition", + "condition", + "connection", + "control", + "cook", + "copper", + "copy", + "corn", + "cough", + "country", + "cover", + "crate", + "crayon", + "cream", + "creator", + "crew", + "crown", + "current", + "curtain", + "curve", + "cushion", + "dad", + "daughter", + "day", + "death", + "debt", + "decision", + "deer", + "degree", + "design", + "desire", + "desk", + "detail", + "development", + "digestion", + "dime", + "dinner", + "direction", + "dirt", + "discovery", + "discussion", + "disease", + "disgust", + "distance", + "distribution", + "division", + "doctor", + "dog", + "door", + "drain", + "drawer", + "dress", + "drink", + "driving", + "dust", + "ear", + "earth", + "edge", + "education", + "effect", + "egg", + "end", + "energy", + "engine", + "error", + "event", + "example", + "exchange", + "existence", + "expansion", + "experience", + "expert", + "eye", + "face", + "fact", + "fall", + "family", + "farm", + "father", + "fear", + "feeling", + "field", + "finger", + "fire", + "fish", + "flag", + "flight", + "floor", + "flower", + "fold", + "food", + "football", + "force", + "form", + "frame", + "friend", + "frog", + "fruit", + "fuel", + "furniture", + "game", + "garden", + "gate", + "girl", + "glass", + "glove", + "goat", + "gold", + "government", + "grade", + "grain", + "grass", + "green", + "grip", + "group", + "growth", + "guide", + "guitar", + "hair", + "hall", + "hand", + "harbor", + "harmony", + "hat", + "head", + "health", + "heart", + "heat", + "hill", + "history", + "hobbies", + "hole", + "hope", + "horn", + "horse", + "hospital", + "hour", + "house", + "humor", + "idea", + "impulse", + "income", + "increase", + "industry", + "ink", + "insect", + "instrument", + "insurance", + "interest", + "invention", + "iron", + "island", + "jelly", + "jet", + "jewel", + "join", + "judge", + "juice", + "jump", + "kettle", + "key", + "kick", + "kiss", + "kitten", + "knee", + "knife", + "knowledge", + "land", + "language", + "laugh", + "law", + "lead", + "learning", + "leather", + "leg", + "lettuce", + "level", + "library", + "lift", + "light", + "limit", + "line", + "linen", + "lip", + "liquid", + "list", + "look", + "loss", + "love", + "lunch", + "machine", + "man", + "manager", + "map", + "marble", + "mark", + "market", + "mass", + "match", + "meal", + "measure", + "meat", + "meeting", + "memory", + "metal", + "middle", + "milk", + "mind", + "mine", + "minute", + "mist", + "mitten", + "mom", + "money", + "monkey", + "month", + "moon", + "morning", + "mother", + "motion", + "mountain", + "mouth", + "muscle", + "music", + "nail", + "name", + "nation", + "neck", + "need", + "news", + "night", + "noise", + "note", + "number", + "nut", + "observation", + "offer", + "oil", + "operation", + "opinion", + "orange", + "order", + "organization", + "ornament", + "oven", + "page", + "pail", + "pain", + "paint", + "pan", + "pancake", + "paper", + "parcel", + "parent", + "part", + "passenger", + "paste", + "payment", + "peace", + "pear", + "pen", + "pencil", + "person", + "pest", + "pet", + "picture", + "pie", + "pin", + "pipe", + "pizza", + "place", + "plane", + "plant", + "plastic", + "plate", + "play", + "pleasure", + "plot", + "plough", + "pocket", + "point", + "poison", + "police", + "pollution", + "popcorn", + "porter", + "position", + "pot", + "potato", + "powder", + "power", + "price", + "print", + "process", + "produce", + "product", + "profit", + "property", + "prose", + "protest", + "pull", + "pump", + "punishment", + "purpose", + "push", + "quarter", + "question", + "quiet", + "quill", + "quilt", + "quince", + "rabbit", + "rail", + "rain", + "range", + "rat", + "rate", + "ray", + "reaction", + "reading", + "reason", + "record", + "regret", + "relation", + "religion", + "representative", + "request", + "respect", + "rest", + "reward", + "rhythm", + "rice", + "river", + "road", + "roll", + "room", + "root", + "rose", + "route", + "rub", + "rule", + "run", + "sack", + "sail", + "salt", + "sand", + "scale", + "scarecrow", + "scarf", + "scene", + "scent", + "school", + "science", + "scissors", + "screw", + "sea", + "seat", + "secretary", + "seed", + "selection", + "self", + "sense", + "servant", + "shade", + "shake", + "shame", + "shape", + "sheep", + "sheet", + "shelf", + "ship", + "shirt", + "shock", + "shoe", + "shop", + "show", + "side", + "sign", + "silk", + "sink", + "sister", + "size", + "sky", + "sleep", + "smash", + "smell", + "smile", + "smoke", + "snail", + "snake", + "sneeze", + "snow", + "soap", + "society", + "sock", + "soda", + "sofa", + "son", + "song", + "sort", + "sound", + "soup", + "space", + "spark", + "speed", + "sponge", + "spoon", + "spray", + "spring", + "spy", + "square", + "stamp", + "star", + "start", + "statement", + "station", + "steam", + "steel", + "stem", + "step", + "stew", + "stick", + "stitch", + "stocking", + "stomach", + "stone", + "stop", + "store", + "story", + "stove", + "stranger", + "straw", + "stream", + "street", + "stretch", + "string", + "structure", + "substance", + "sugar", + "suggestion", + "suit", + "summer", + "sun", + "support", + "surprise", + "sweater", + "swim", + "system", + "table", + "tail", + "talk", + "tank", + "taste", + "tax", + "tea", + "teaching", + "team", + "tendency", + "test", + "texture", + "theory", + "thing", + "thought", + "thread", + "throat", + "thumb", + "thunder", + "ticket", + "time", + "tin", + "title", + "toad", + "toe", + "tooth", + "toothpaste", + "touch", + "town", + "toy", + "trade", + "train", + "transport", + "tray", + "treatment", + "tree", + "trick", + "trip", + "trouble", + "trousers", + "truck", + "tub", + "turkey", + "turn", + "twist", + "umbrella", + "uncle", + "underwear", + "unit", + "use", + "vacation", + "value", + "van", + "vase", + "vegetable", + "veil", + "vein", + "verse", + "vessel", + "view", + "visitor", + "voice", + "volcano", + "walk", + "wall", + "war", + "wash", + "waste", + "watch", + "water", + "wave", + "wax", + "way", + "wealth", + "weather", + "week", + "weight", + "wheel", + "whip", + "whistle", + "window", + "wine", + "wing", + "winter", + "wire", + "wish", + "woman", + "wood", + "wool", + "word", + "work", + "worm", + "wound", + "wrist", + "writer", + "yard", + "yoke", + "zebra", + "zinc", + "zipper", + "zone", +]; + +/** + * Generate a random name. + * @returns {string} A random name. + */ +export function randomName(): string { + const adjective = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + const number = Math.floor(Math.random() * 100); + return `${adjective}-${noun}-${number}`; +} diff --git a/src/lib/evaluation/_runner.ts b/src/lib/evaluation/_runner.ts new file mode 100644 index 0000000..8ddfd6b --- /dev/null +++ b/src/lib/evaluation/_runner.ts @@ -0,0 +1,1555 @@ +import { Client, RunTree, RunTreeConfig } from "../index.js"; +import { + AttachmentInfo, + BaseRun, + Example, + KVMap, + Run, + TracerSession, +} from "../schemas.js"; +import { isTraceableFunction, traceable } from "../traceable.js"; +import { getDefaultRevisionId, getGitInfo } from "../utils/_git.js"; +import { assertUuid } from "../utils/_uuid.js"; +import { atee } from "../utils/atee.js"; +import { getLangSmithEnvVarsMetadata } from "../utils/env.js"; +import { printErrorStackTrace } from "../utils/error.js"; +import { randomName } from "./_random_name.js"; +import { + EvaluationResult, + EvaluationResults, + RunEvaluator, + runEvaluator, +} from "./evaluator.js"; +import { LangSmithConflictError } from "../utils/error.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { + evaluateComparative, + ComparisonEvaluationResults, + ComparativeEvaluator, +} from "./evaluate_comparative.js"; +import { PQueue, PQueueType } from "../utils/p-queue.js"; + +export type TargetConfigT = KVMap & { + attachments?: Record; + callbacks?: any; +}; +type StandardTargetT = + | ((input: TInput, config?: TargetConfigT) => Promise) + | ((input: TInput, config?: TargetConfigT) => TOutput) + | { invoke: (input: TInput, config?: TargetConfigT) => TOutput } + | { invoke: (input: TInput, config?: TargetConfigT) => Promise }; + +type ComparativeTargetT = + | Array + | Array | ExperimentResults>; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TargetT = + | StandardTargetT + | ComparativeTargetT; + +// Data format: dataset-name, dataset_id, or examples +export type DataT = string | AsyncIterable | Example[]; + +// and reports aggregate metric(s) +/** @deprecated Use object parameter version instead: (args: { runs, examples, inputs, outputs, referenceOutputs }) => ... */ +type DeprecatedSyncSummaryEvaluator = ( + runs: Array, + examples: Array, +) => EvaluationResult | EvaluationResult[] | EvaluationResults; + +/** @deprecated Use object parameter version instead: (args: { runs, examples, inputs, outputs, referenceOutputs }) => ... */ +type DeprecatedAsyncSummaryEvaluator = ( + runs: Array, + examples: Array, +) => Promise; + +// Summary evaluator runs over the whole dataset +export type SummaryEvaluatorT = + | DeprecatedSyncSummaryEvaluator + | DeprecatedAsyncSummaryEvaluator + | ((args: { + runs: Array; + examples: Array; + inputs: Array>; + outputs: Array>; + referenceOutputs?: Array>; + }) => EvaluationResult | EvaluationResult[] | EvaluationResults) + | ((args: { + runs: Array; + examples: Array; + inputs: Array>; + outputs: Array>; + referenceOutputs?: Array>; + }) => Promise); + +/** @deprecated Use object parameter version instead: (args: { run, example, inputs, outputs, referenceOutputs }) => ... */ +type DeprecatedRunEvaluator = RunEvaluator; + +/** @deprecated Use object parameter version instead: (args: { run, example, inputs, outputs, referenceOutputs }) => ... */ +type DeprecatedFunctionEvaluator = ( + run: Run, + example?: Example, +) => EvaluationResult | EvaluationResult[] | EvaluationResults; + +/** @deprecated Use object parameter version instead: (args: { run, example, inputs, outputs, referenceOutputs }) => ... */ +type DeprecatedAsyncFunctionEvaluator = ( + run: Run, + example?: Example, +) => Promise; + +// Row-level evaluator +export type EvaluatorT = + | DeprecatedRunEvaluator + | DeprecatedFunctionEvaluator + | DeprecatedAsyncFunctionEvaluator + | ((args: { + run: Run; + example: Example; + inputs: Record; + outputs: Record; + referenceOutputs?: Record; + attachments?: Record; + }) => EvaluationResult | EvaluationResult[] | EvaluationResults) + | ((args: { + run: Run; + example: Example; + inputs: Record; + outputs: Record; + referenceOutputs?: Record; + attachments?: Record; + }) => Promise); + +interface _ForwardResults { + run: Run; + example: Example; + exampleIndex: number; +} + +interface _ExperimentManagerArgs { + data?: DataT; + experiment?: TracerSession | string; + metadata?: KVMap; + client?: Client; + runs?: AsyncGenerator; + evaluationResults?: AsyncGenerator; + summaryResults?: AsyncGenerator< + (runsArray: Run[]) => AsyncGenerator, + any, + unknown + >; + examples?: Example[]; + numRepetitions?: number; + numExamples?: number; + evaluatorKeys?: string[]; + _runsArray?: Run[]; + resultRows?: AsyncGenerator<_ExperimentResultRowWithIndex>; + includeAttachments?: boolean; + disableEvaluatorTracing?: boolean; +} + +type BaseEvaluateOptions = { + /** + * Metadata to attach to the experiment. + * @default undefined + */ + metadata?: KVMap; + /** + * A prefix to provide for your experiment name. + * @default undefined + */ + experimentPrefix?: string; + /** + * A free-form description of the experiment. + */ + description?: string; + /** + * The maximum concurrency to use for predictions/evaluations when a more + * specific concurrency option is not provided. + * @default undefined + */ + maxConcurrency?: number; + /** + * The LangSmith client to use. + * @default undefined + */ + client?: Client; + /** + * The number of repetitions to perform. Each example + * will be run this many times. + * @default 1 + */ + numRepetitions?: number; +}; + +export interface EvaluateOptions extends BaseEvaluateOptions { + /** + * The maximum number of concurrent predictions to run. + * If not provided, defaults to `maxConcurrency` when set. + * @default undefined + */ + targetConcurrency?: number; + /** + * The maximum number of concurrent evaluators to run. + * If not provided, defaults to `maxConcurrency` when set. + * @default undefined + */ + evaluationConcurrency?: number; + /** + * A list of evaluators to run on each example. + * @default undefined + */ + evaluators?: Array; + /** + * A list of summary evaluators to run on the entire dataset. + * @default undefined + */ + summaryEvaluators?: Array; + /** + * The dataset to evaluate on. Can be a dataset name, a list of + * examples, or a generator of examples. + */ + data: DataT; + /** + * Whether to use attachments for the experiment. + * @default false + */ + includeAttachments?: boolean; + /** + * Whether to skip tracing evaluator invocations to the `evaluators` project. + * + * When `true`, evaluators run without creating evaluator traces. Feedback is + * still created and attached to the experiment runs, but it won't link back + * to an evaluator run, which also means it can't be corrected from the UI. + * Tracing of the target itself is unaffected. + * @default false + */ + disableEvaluatorTracing?: boolean; +} + +export interface ComparativeEvaluateOptions extends BaseEvaluateOptions { + /** + * A list of evaluators to run on each example. + */ + evaluators: Array; + /** + * Whether to load all child runs for the experiment. + * @default false + */ + loadNested?: boolean; + /** + * Randomize the order of outputs for each evaluation + * @default false + */ + randomizeOrder?: boolean; +} + +// Function overloads +export function evaluate( + target: ComparativeTargetT, + options: ComparativeEvaluateOptions, +): Promise; + +export function evaluate( + target: StandardTargetT, + options: EvaluateOptions, +): Promise; + +// Implementation signature +export function evaluate( + target: TargetT, + options: EvaluateOptions | ComparativeEvaluateOptions, +): Promise { + return _evaluate(target, options); +} + +export interface ExperimentResultRow { + run: Run; + example: Example; + evaluationResults: EvaluationResults; +} + +interface _ExperimentResultRowWithIndex extends ExperimentResultRow { + exampleIndex: number; +} + +export function _reorderResultRowsByExampleIndex( + rows: _ExperimentResultRowWithIndex[], +): { + orderedRows: ExperimentResultRow[]; + orderedRuns: Run[]; +} { + const sortedRows = [...rows].sort((a, b) => a.exampleIndex - b.exampleIndex); + return { + orderedRows: sortedRows.map(({ run, example, evaluationResults }) => ({ + run, + example, + evaluationResults, + })), + orderedRuns: sortedRows.map((row) => row.run), + }; +} + +/** + * Manage the execution of experiments. + * + * Supports lazily running predictions and evaluations in parallel to facilitate + * result streaming and early debugging. + */ +export class _ExperimentManager { + _data?: DataT; + + _runs?: AsyncGenerator; + + _evaluationResults?: AsyncGenerator; + + _summaryResults?: AsyncGenerator< + (runsArray: Run[]) => AsyncGenerator, + any, + unknown + >; + + _resultRows?: AsyncGenerator<_ExperimentResultRowWithIndex>; + _examples?: Example[]; + _numExamples?: number; + _numRepetitions?: number; + _evaluatorKeys?: string[]; + _runsArray?: Run[]; + client: Client; + _experiment?: TracerSession; + _experimentName: string; + _metadata: KVMap; + _description?: string; + _includeAttachments?: boolean; + _disableEvaluatorTracing?: boolean; + + get experimentName(): string { + if (this._experimentName) { + return this._experimentName; + } else { + throw new Error( + "Experiment name not provided, and experiment not yet started.", + ); + } + } + + async getExamples(): Promise> { + if (!this._examples) { + if (!this._data) { + throw new Error("Data not provided in this experiment."); + } + const unresolvedData = _resolveData(this._data, { + client: this.client, + includeAttachments: this._includeAttachments, + }); + if (!this._examples) { + this._examples = []; + } + const exs = []; + for await (const example of unresolvedData) { + exs.push(example); + } + if (this._numRepetitions && this._numRepetitions > 0) { + const repeatedExamples = []; + for (let i = 0; i < this._numRepetitions; i++) { + repeatedExamples.push(...exs); + } + this.setExamples(repeatedExamples); + } else { + this.setExamples(exs); + } + } + return this._examples; + } + + setExamples(examples: Example[]): void { + this._examples = examples; + } + + get datasetId(): Promise { + return this.getExamples().then((examples) => { + if (examples.length === 0) { + throw new Error("No examples found in the dataset."); + } + if (this._experiment && this._experiment.reference_dataset_id) { + return this._experiment.reference_dataset_id; + } + return examples[0].dataset_id; + }); + } + + get evaluationResults(): AsyncGenerator { + if (this._evaluationResults === undefined) { + return async function* (this: _ExperimentManager) { + for (const _ of await this.getExamples()) { + yield { results: [] }; + } + }.call(this); + } else { + return this._evaluationResults; + } + } + + get runs(): AsyncGenerator { + if (this._runsArray && this._runsArray.length > 0) { + throw new Error("Runs already provided as an array."); + } + if (this._runs === undefined) { + throw new Error( + "Runs not provided in this experiment. Please predict first.", + ); + } else { + return this._runs; + } + } + + constructor(args: _ExperimentManagerArgs) { + this.client = args.client ?? new Client(); + if (!args.experiment) { + this._experimentName = randomName(); + } else if (typeof args.experiment === "string") { + this._experimentName = `${args.experiment}-${uuidv4().slice(0, 8)}`; + } else { + if (!args.experiment.name) { + throw new Error("Experiment must have a name"); + } + this._experimentName = args.experiment.name; + this._experiment = args.experiment; + } + + let metadata = args.metadata || {}; + if (!("revision_id" in metadata)) { + metadata = { + revision_id: getLangSmithEnvVarsMetadata().revision_id, + ...metadata, + }; + } + this._metadata = metadata; + + if (args.examples && args.examples.length) { + this.setExamples(args.examples); + } + this._data = args.data; + + if (args._runsArray && args._runsArray.length) { + this._runsArray = args._runsArray; + } + this._runs = args.runs; + + this._evaluationResults = args.evaluationResults; + this._summaryResults = args.summaryResults; + this._resultRows = args.resultRows; + this._numRepetitions = args.numRepetitions; + this._numExamples = args.numExamples; + this._evaluatorKeys = args.evaluatorKeys; + this._includeAttachments = args.includeAttachments; + this._disableEvaluatorTracing = args.disableEvaluatorTracing; + } + + _getExperiment(): TracerSession { + if (!this._experiment) { + throw new Error("Experiment not yet started."); + } + return this._experiment; + } + + async _getExperimentMetadata(): Promise { + let projectMetadata = this._metadata ?? {}; + const gitInfo = await getGitInfo(); + if (gitInfo) { + projectMetadata = { + ...projectMetadata, + git: gitInfo, + }; + } + if (this._experiment) { + const experimentMetadata: KVMap = + this._experiment.extra && "metadata" in this._experiment.extra + ? this._experiment.extra.metadata + : {}; + projectMetadata = { + ...experimentMetadata, + ...projectMetadata, + }; + } + return projectMetadata; + } + + async _createProject(firstExample: Example, projectMetadata: KVMap) { + // Create the project, updating the experimentName until we find a unique one. + let project: TracerSession; + const originalExperimentName = this._experimentName; + if (this._numExamples === undefined) { + this._numExamples = await _resolveNumExamples(this._data, this.client); + } + const numExamples = this._numExamples ?? null; + const numRepetitions = this._numRepetitions ?? null; + const evaluatorKeys = this._evaluatorKeys ?? null; + for (let i = 0; i < 10; i++) { + try { + project = await this.client.createProject({ + projectName: this._experimentName, + referenceDatasetId: firstExample.dataset_id, + metadata: projectMetadata, + description: this._description, + numExamples, + numRepetitions, + evaluatorKeys, + }); + return project; + } catch (e) { + // Naming collision + if ((e as LangSmithConflictError)?.name === "LangSmithConflictError") { + const ent = uuidv4().slice(0, 6); + this._experimentName = `${originalExperimentName}-${ent}`; + } else { + throw e; + } + } + } + throw new Error( + "Could not generate a unique experiment name within 10 attempts." + + " Please try again with a different name.", + ); + } + + async _getProject(firstExample: Example): Promise { + let project: TracerSession; + if (!this._experiment) { + const projectMetadata = await this._getExperimentMetadata(); + project = await this._createProject(firstExample, projectMetadata); + this._experiment = project; + } + return this._experiment; + } + + protected async _printExperimentStart(): Promise { + console.log(`Starting evaluation of experiment: ${this.experimentName}`); + + const firstExample = this._examples?.[0]; + const datasetId = firstExample?.dataset_id; + if (!datasetId || !this._experiment) return; + + const datasetUrl = await this.client.getDatasetUrl({ datasetId }); + const compareUrl = `${datasetUrl}/compare?selectedSessions=${this._experiment.id}`; + + console.log(`View results at ${compareUrl}`); + } + + async start(): Promise<_ExperimentManager> { + const examples = await this.getExamples(); + const firstExample = examples[0]; + const project = await this._getProject(firstExample); + await this._printExperimentStart(); + this._metadata["num_repetitions"] = this._numRepetitions; + return new _ExperimentManager({ + examples, + experiment: project, + metadata: this._metadata, + client: this.client, + runs: this._runs, + _runsArray: this._runsArray, + evaluationResults: this._evaluationResults, + summaryResults: this._summaryResults, + numRepetitions: this._numRepetitions, + numExamples: this._numExamples, + evaluatorKeys: this._evaluatorKeys, + includeAttachments: this._includeAttachments, + disableEvaluatorTracing: this._disableEvaluatorTracing, + }); + } + + async withPredictions( + target: StandardTargetT, + options?: { + maxConcurrency?: number; + queue?: PQueueType; + }, + ): Promise<_ExperimentManager> { + const experimentResults = this._predict(target, options); + const [rowsForResults, rowsForRuns] = + atee<_ForwardResults>(experimentResults); + return new _ExperimentManager({ + examples: await this.getExamples(), + experiment: this._experiment, + metadata: this._metadata, + client: this.client, + resultRows: + (async function* (): AsyncGenerator<_ExperimentResultRowWithIndex> { + for await (const pred of rowsForResults) { + yield { + run: pred.run, + example: pred.example, + evaluationResults: { results: [] }, + exampleIndex: pred.exampleIndex, + }; + } + })(), + runs: (async function* (): AsyncGenerator { + for await (const pred of rowsForRuns) { + yield pred.run; + } + })(), + numRepetitions: this._numRepetitions, + numExamples: this._numExamples, + evaluatorKeys: this._evaluatorKeys, + includeAttachments: this._includeAttachments, + disableEvaluatorTracing: this._disableEvaluatorTracing, + }); + } + + async withEvaluators( + evaluators: Array, + options?: { + maxConcurrency?: number; + queue?: PQueueType; + }, + ): Promise<_ExperimentManager> { + const resolvedEvaluators = _resolveEvaluators(evaluators); + const experimentResults = this._score(resolvedEvaluators, options); + const [rowsForResults, rowsForRuns, rowsForEvaluations] = + atee<_ExperimentResultRowWithIndex>(experimentResults, 3); + + return new _ExperimentManager({ + examples: await this.getExamples(), + experiment: this._experiment, + metadata: this._metadata, + client: this.client, + resultRows: rowsForResults, + runs: (async function* (): AsyncGenerator { + for await (const result of rowsForRuns) { + yield result.run; + } + })(), + evaluationResults: + (async function* (): AsyncGenerator { + for await (const result of rowsForEvaluations) { + yield result.evaluationResults; + } + })(), + summaryResults: this._summaryResults, + numRepetitions: this._numRepetitions, + numExamples: this._numExamples, + evaluatorKeys: this._evaluatorKeys, + includeAttachments: this._includeAttachments, + disableEvaluatorTracing: this._disableEvaluatorTracing, + }); + } + + async withSummaryEvaluators( + summaryEvaluators: Array, + ): Promise<_ExperimentManager> { + const aggregateFeedbackGen = + this._applySummaryEvaluators(summaryEvaluators); + return new _ExperimentManager({ + examples: await this.getExamples(), + experiment: this._experiment, + metadata: this._metadata, + client: this.client, + runs: this._runs, + _runsArray: this._runsArray, + evaluationResults: this._evaluationResults, + resultRows: this._resultRows, + summaryResults: aggregateFeedbackGen, + numRepetitions: this._numRepetitions, + numExamples: this._numExamples, + evaluatorKeys: this._evaluatorKeys, + includeAttachments: this._includeAttachments, + disableEvaluatorTracing: this._disableEvaluatorTracing, + }); + } + + async *getResults(): AsyncGenerator<_ExperimentResultRowWithIndex> { + if (!this._runsArray) { + this._runsArray = []; + } + + if (this._resultRows) { + for await (const result of this._resultRows) { + this._runsArray.push(result.run); + yield result; + } + return; + } + + const examples = await this.getExamples(); + const runsIterator = this.runs[Symbol.asyncIterator](); + const evaluationIterator = this.evaluationResults[Symbol.asyncIterator](); + + for (let i = 0; i < examples.length; i++) { + const runResult = await runsIterator.next(); + if (runResult.done) { + break; + } + const evaluationResult = await evaluationIterator.next(); + const evaluationResults = evaluationResult.done + ? { results: [] } + : evaluationResult.value; + + this._runsArray.push(runResult.value); + + yield { + run: runResult.value, + example: examples[i], + evaluationResults, + exampleIndex: i, + }; + } + } + + async getSummaryScores(): Promise { + if (!this._summaryResults) { + return { results: [] }; + } + + const results: EvaluationResult[] = []; + for await (const evaluationResultsGenerator of this._summaryResults) { + if (typeof evaluationResultsGenerator === "function") { + // This is because runs array is not available until after this generator + // is set, so we need to pass it like so. + for await (const evaluationResults of evaluationResultsGenerator( + this._runsArray ?? [], + )) { + results.push(...evaluationResults.results); + } + } + } + + return { results }; + } + + // Private methods + + /** + * Run the target function or runnable on the examples. + * @param {StandardTargetT} target The target function or runnable to evaluate. + * @param options + * @returns {AsyncGenerator<_ForwardResults>} An async generator of the results. + */ + async *_predict( + target: StandardTargetT, + options?: { + maxConcurrency?: number; + queue?: PQueueType; + }, + ): AsyncGenerator<_ForwardResults> { + const maxConcurrency = options?.maxConcurrency ?? 0; + const examples = await this.getExamples(); + let hadPredictionError = false; + let shouldThrowEndError = false; + let endErrorToThrow: unknown; + try { + // maxConcurrency: 0 means sequential execution (matching Python behavior) + const queue = + options?.queue ?? + new PQueue({ + concurrency: maxConcurrency === 0 ? 1 : maxConcurrency, + }); + + const examplesWithIndex = examples.map((example, i) => ({ + example, + exampleIndex: i, + })); + + for await (const result of _mapWithConcurrency( + examplesWithIndex, + queue, + (item) => + _forward( + target, + item.example, + this.experimentName, + this._metadata, + this.client, + this._includeAttachments, + ).then((forwardResult) => ({ + ...forwardResult, + exampleIndex: item.exampleIndex, + })), + )) { + yield result; + } + } catch (error) { + hadPredictionError = true; + throw error; + } finally { + try { + // Always attempt to close out the project metadata, even on prediction errors. + await this._end(); + } catch (endError) { + if (hadPredictionError) { + console.error(`Error finalizing experiment: ${endError}`); + printErrorStackTrace(endError); + } else { + shouldThrowEndError = true; + endErrorToThrow = endError; + } + } + } + if (shouldThrowEndError) { + throw endErrorToThrow; + } + } + + async _runEvaluators( + evaluators: Array, + currentResults: _ExperimentResultRowWithIndex, + fields: { + client: Client; + }, + ): Promise<_ExperimentResultRowWithIndex> { + const { run, example, evaluationResults, exampleIndex } = currentResults; + for (const evaluator of evaluators) { + try { + const options = { + reference_example_id: example.id, + project_name: "evaluators", + metadata: { + example_version: example.modified_at + ? new Date(example.modified_at).toISOString() + : new Date(example.created_at).toISOString(), + }, + client: fields.client, + tracingEnabled: !this._disableEvaluatorTracing, + }; + const rawEvaluatorResponse = await evaluator.evaluateRun( + run, + example, + options, + ); + // With tracing off there is no evaluator run for the feedback to point + // at, so drop the link rather than leaving it dangling. + const evaluatorResponse = this._disableEvaluatorTracing + ? _withoutSourceRunIds(rawEvaluatorResponse) + : rawEvaluatorResponse; + evaluationResults.results.push( + ...(await fields.client.logEvaluationFeedback({ + evaluatorResponse, + run, + projectId: this._getExperiment().id, + })), + ); + } catch (e) { + console.error( + `Error running evaluator ${evaluator.evaluateRun.name} on run ${run.id}: ${e}`, + ); + printErrorStackTrace(e); + } + } + + return { + run, + example, + evaluationResults, + exampleIndex, + }; + } + + /** + * Run the evaluators on the prediction stream. + * Expects runs to be available in the manager. + * (e.g. from a previous prediction step) + * @param {Array} evaluators + * @param {number} maxConcurrency + */ + async *_score( + evaluators: Array, + options?: { + maxConcurrency?: number; + queue?: PQueueType; + }, + ): AsyncGenerator<_ExperimentResultRowWithIndex> { + const { maxConcurrency = 0, queue: providedQueue } = options || {}; + + // maxConcurrency: 0 means sequential execution (matching Python behavior) + const queue = + providedQueue ?? + new PQueue({ + concurrency: maxConcurrency === 0 ? 1 : maxConcurrency, + }); + + for await (const result of _mapWithConcurrency( + this.getResults(), + queue, + (currentResults) => + this._runEvaluators(evaluators, currentResults, { + client: this.client, + }), + )) { + yield result; + } + } + + async *_applySummaryEvaluators( + summaryEvaluators: Array, + ): AsyncGenerator<(runsArray: Run[]) => AsyncGenerator> { + const projectId = this._getExperiment().id; + const examples = await this.getExamples(); + + const options = Array.from({ length: summaryEvaluators.length }).map( + () => ({ + project_name: "evaluators", + experiment: this.experimentName, + projectId: projectId, + }), + ); + const wrappedEvaluators = await wrapSummaryEvaluators( + summaryEvaluators, + options, + // Only override when disabling. Passing `true` here would force tracing + // on for callers who have it switched off via the environment. + this._disableEvaluatorTracing ? false : undefined, + ); + + yield async function* ( + this: _ExperimentManager, + runsArray: Run[], + ): AsyncGenerator { + const aggregateFeedback = []; + + for (const evaluator of wrappedEvaluators) { + try { + const summaryEvalResult = await evaluator(runsArray, examples); + + const flattenedResults = + this.client._selectEvalResults(summaryEvalResult); + aggregateFeedback.push(...flattenedResults); + for (const result of flattenedResults) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { targetRunId, key, ...feedback } = result; + const evaluatorInfo = feedback.evaluatorInfo; + delete feedback.evaluatorInfo; + + await this.client.createFeedback(null, key, { + ...feedback, + projectId: projectId, + sourceInfo: evaluatorInfo, + }); + } + } catch (e) { + console.error( + `Error running summary evaluator ${ + evaluator.name + }: ${JSON.stringify(e, null, 2)}`, + ); + printErrorStackTrace(e); + } + } + + yield { + results: aggregateFeedback, + }; + }.bind(this); + } + + async _getDatasetVersion(): Promise { + const examples = await this.getExamples(); + const modifiedAt = examples.map((ex) => ex.modified_at); + + // Python might return microseconds, which we need + // to account for when comparing dates. + const modifiedAtTime = modifiedAt.map((date) => { + function getMiliseconds(isoString: string) { + const time = isoString.split("T").at(1); + if (!time) return ""; + + const regex = /[0-9]{2}:[0-9]{2}:[0-9]{2}.([0-9]+)/; + const strMiliseconds = time.match(regex)?.[1]; + return strMiliseconds ?? ""; + } + + const jsDate = new Date(date!); + + let source = getMiliseconds(date!); + let parsed = getMiliseconds(jsDate.toISOString()); + + const length = Math.max(source.length, parsed.length); + source = source.padEnd(length, "0"); + parsed = parsed.padEnd(length, "0"); + + const microseconds = + (Number.parseInt(source, 10) - Number.parseInt(parsed, 10)) / 1000; + + const time = jsDate.getTime() + microseconds; + return { date, time }; + }); + + if (modifiedAtTime.length === 0) return undefined; + return modifiedAtTime.reduce( + (max, current) => (current.time > max.time ? current : max), + modifiedAtTime[0], + ).date; + } + + async _getDatasetSplits(): Promise { + const examples = await this.getExamples(); + const allSplits = examples.reduce((acc, ex) => { + if (ex.metadata && ex.metadata.dataset_split) { + if (Array.isArray(ex.metadata.dataset_split)) { + ex.metadata.dataset_split.forEach((split) => acc.add(split)); + } else if (typeof ex.metadata.dataset_split === "string") { + acc.add(ex.metadata.dataset_split); + } + } + return acc; + }, new Set()); + return allSplits.size ? Array.from(allSplits) : undefined; + } + + async _end(): Promise { + const experiment = this._experiment; + if (!experiment) { + throw new Error("Experiment not yet started."); + } + const projectMetadata = await this._getExperimentMetadata(); + projectMetadata["dataset_version"] = await this._getDatasetVersion(); + projectMetadata["dataset_splits"] = await this._getDatasetSplits(); + // Update revision_id if not already set + if (!projectMetadata["revision_id"]) { + projectMetadata["revision_id"] = await getDefaultRevisionId(); + } + + await this.client.updateProject(experiment.id, { + metadata: projectMetadata, + }); + } +} + +/** + * Represents the results of an evaluate() call. + * This class provides an iterator interface to iterate over the experiment results + * as they become available. It also provides methods to access the experiment name, + * the number of results, and to wait for the results to be processed. + */ +class ExperimentResults implements AsyncIterableIterator { + private manager: _ExperimentManager; + results: ExperimentResultRow[] = []; + processedCount = 0; + summaryResults: EvaluationResults; + + constructor(experimentManager: _ExperimentManager) { + this.manager = experimentManager; + } + + get experimentName(): string { + return this.manager.experimentName; + } + + [Symbol.asyncIterator](): AsyncIterableIterator { + return this; + } + + async next(): Promise> { + if (this.processedCount < this.results.length) { + const result = this.results[this.processedCount]; + this.processedCount++; + return Promise.resolve({ value: result, done: false }); + } else { + return Promise.resolve({ value: undefined, done: true }); + } + } + + async processData(manager: _ExperimentManager): Promise { + const unorderedResults: _ExperimentResultRowWithIndex[] = []; + for await (const item of manager.getResults()) { + unorderedResults.push(item); + } + + const { orderedRows, orderedRuns } = + _reorderResultRowsByExampleIndex(unorderedResults); + manager._runsArray = orderedRuns; + this.results = orderedRows; + this.processedCount = this.results.length; + + this.summaryResults = await manager.getSummaryScores(); + } + + get length(): number { + return this.results.length; + } +} + +async function _evaluate( + target: TargetT | AsyncGenerator, + fields: (EvaluateOptions | ComparativeEvaluateOptions) & { + experiment?: TracerSession; + }, +): Promise { + // Add check for comparative evaluation + if (Array.isArray(target)) { + const comparativeOptions = fields as ComparativeEvaluateOptions; + if (!comparativeOptions.evaluators) { + throw new Error("Evaluators are required for comparative evaluation"); + } + + return evaluateComparative(target, { + evaluators: comparativeOptions.evaluators, + client: comparativeOptions.client, + metadata: comparativeOptions.metadata, + experimentPrefix: comparativeOptions.experimentPrefix, + description: comparativeOptions.description, + maxConcurrency: comparativeOptions.maxConcurrency, + loadNested: comparativeOptions.loadNested ?? false, + randomizeOrder: comparativeOptions.randomizeOrder ?? false, + }); + } + + const client = fields.client ?? new Client(); + const runs = _isCallable(target) ? null : (target as AsyncGenerator); + const standardFields = fields as EvaluateOptions; + const [experiment_, newRuns] = await _resolveExperiment( + fields.experiment ?? null, + runs, + client, + ); + + let manager = await new _ExperimentManager({ + data: standardFields.data, + client, + metadata: fields.metadata, + experiment: experiment_ ?? fields.experimentPrefix, + runs: newRuns ?? undefined, + numRepetitions: fields.numRepetitions ?? 1, + evaluatorKeys: _collectEvaluatorKeys(standardFields.evaluators), + includeAttachments: standardFields.includeAttachments, + disableEvaluatorTracing: standardFields.disableEvaluatorTracing, + }).start(); + + const targetConcurrency = + standardFields.targetConcurrency ?? standardFields.maxConcurrency ?? 0; + const evaluationConcurrency = + standardFields.evaluationConcurrency ?? standardFields.maxConcurrency ?? 0; + + // Determine if we should use separate queues or a shared queue + const useSeparateQueues = + standardFields.targetConcurrency !== undefined && + standardFields.evaluationConcurrency !== undefined; + + let sharedQueue: PQueueType | undefined; + let targetQueue: PQueueType | undefined; + let evaluationQueue: PQueueType | undefined; + + if (useSeparateQueues) { + // Create separate queues for target and evaluation + if (targetConcurrency > 0) { + targetQueue = new PQueue({ concurrency: targetConcurrency }); + } + if (evaluationConcurrency > 0) { + evaluationQueue = new PQueue({ concurrency: evaluationConcurrency }); + } + } else { + // Use a shared queue + const sharedConcurrency = standardFields.maxConcurrency ?? 0; + if (sharedConcurrency > 0) { + sharedQueue = new PQueue({ concurrency: sharedConcurrency }); + } + } + + if (_isCallable(target)) { + manager = await manager.withPredictions(target, { + maxConcurrency: targetConcurrency, + queue: useSeparateQueues ? targetQueue : sharedQueue, + }); + } + + if (standardFields.evaluators) { + manager = await manager.withEvaluators(standardFields.evaluators, { + maxConcurrency: evaluationConcurrency, + queue: useSeparateQueues ? evaluationQueue : sharedQueue, + }); + } + if (standardFields.summaryEvaluators) { + manager = await manager.withSummaryEvaluators( + standardFields.summaryEvaluators, + ); + } + // Start consuming the results. + const results = new ExperimentResults(manager); + await results.processData(manager); + await client.awaitPendingTraceBatches(); + return results; +} + +async function _forward( + fn: StandardTargetT, + example: Example, + experimentName: string, + metadata: KVMap, + client: Client, + includeAttachments?: boolean, +): Promise> { + let run: BaseRun | undefined = undefined; + + const _getRun = (r?: RunTree): void => { + run = r; + }; + + const defaultOptions = { + reference_example_id: example.id, + on_start: _getRun, + project_name: experimentName, + metadata: { + ...metadata, + example_version: example.modified_at + ? new Date(example.modified_at).toISOString() + : new Date(example.created_at).toISOString(), + }, + client, + tracingEnabled: true, + }; + + const wrappedFn = isTraceableFunction(fn) + ? fn + : "invoke" in fn + ? traceable(async (inputs) => { + let langChainCallbacks; + try { + // TODO: Deprecate this and rely on interop on 0.2 minor bump. + const { getLangchainCallbacks } = await import("../langchain.js"); + langChainCallbacks = await getLangchainCallbacks(); + } catch { + // no-op + } + // Issue with retrieving LangChain callbacks, rely on interop + if (langChainCallbacks === undefined && !includeAttachments) { + return await fn.invoke(inputs); + } else if (langChainCallbacks === undefined && includeAttachments) { + return await fn.invoke(inputs, { + attachments: example.attachments, + }); + } else if (!includeAttachments) { + return await fn.invoke(inputs, { callbacks: langChainCallbacks }); + } else { + return await fn.invoke(inputs, { + attachments: example.attachments, + callbacks: langChainCallbacks, + }); + } + }, defaultOptions) + : traceable(fn, defaultOptions); + + try { + if (includeAttachments && !("invoke" in fn)) { + await wrappedFn(example.inputs, { attachments: example.attachments }); + } else { + await wrappedFn(example.inputs); + } + } catch (e) { + console.error(`Error running target function: ${e}`); + printErrorStackTrace(e); + } + + if (!run) { + throw new Error(`Run not created by target function. +This is most likely due to tracing not being enabled.\n +Try setting "LANGSMITH_TRACING=true" in your environment.`); + } + + return { + run, + example, + }; +} + +// best effort feedback key extraction using regex parsing. +// may be fragile but seems okay as a best effort option and doesn't break anything significant. +// TODO: find a better way to resolve this. +export function _extractEvaluatorFeedbackKeys( + evaluator: EvaluatorT | RunEvaluator, +): string[] { + const name = (evaluator as { name?: string }).name; + const fallback = name && name.length > 0 ? [name] : []; + try { + const source = typeof evaluator === "function" ? evaluator.toString() : ""; + if (!source) return fallback; + // Match `key: "literal"`, `"key": "literal"`, or backtick literals without + // interpolation. Word boundaries avoid matching `keys`, and the required + // colon + quoted value avoids matching destructuring/`for (const key of)`. + const regex = /["']?\bkey\b["']?\s*:\s*(?:"([^"]+)"|'([^']+)'|`([^`$]+)`)/g; + const keys: string[] = []; + let match: RegExpExecArray | null; + while ((match = regex.exec(source)) !== null) { + const value = match[1] ?? match[2] ?? match[3]; + if (value) keys.push(value); + } + const unique = [...new Set(keys)]; + return unique.length > 0 ? unique : fallback; + } catch { + return fallback; + } +} + +export function _collectEvaluatorKeys( + evaluators: Array | undefined, +): string[] { + if (!evaluators) return []; + const keys: string[] = []; + for (const ev of evaluators) { + keys.push(..._extractEvaluatorFeedbackKeys(ev)); + } + return keys; +} + +async function _resolveNumExamples( + data: DataT | undefined, + client: Client, +): Promise { + // Best-effort dataset size for the experiment progress hint. Returns + // undefined for lazy async iterators where size cannot be inferred without + // consuming. + if (data === undefined) return undefined; + try { + if (Array.isArray(data)) { + return data.length; + } + if (typeof data === "string") { + let isUUID = false; + try { + assertUuid(data); + isUUID = true; + } catch (_) { + isUUID = false; + } + const dataset = isUUID + ? await client.readDataset({ datasetId: data }) + : await client.readDataset({ datasetName: data }); + return dataset.example_count ?? undefined; + } + return undefined; + } catch (_) { + return undefined; + } +} + +function _resolveData( + data: DataT, + options: { + client: Client; + includeAttachments?: boolean; + }, +): AsyncGenerator { + let isUUID = false; + try { + if (typeof data === "string") { + assertUuid(data); + isUUID = true; + } + } catch (_) { + isUUID = false; + } + + if (typeof data === "string" && isUUID) { + return options.client.listExamples({ + datasetId: data, + includeAttachments: options.includeAttachments, + }) as AsyncGenerator; + } + if (typeof data === "string") { + return options.client.listExamples({ + datasetName: data, + includeAttachments: options.includeAttachments, + }) as AsyncGenerator; + } + return data as AsyncGenerator; +} + +/** + * Strip evaluator-run links from an evaluator response. + * + * Used when evaluator tracing is disabled: no evaluator run is created, so a + * `sourceRunId` on the feedback would point at a run that does not exist. + */ +function _withoutSourceRunIds( + response: EvaluationResult | EvaluationResults, +): EvaluationResult | EvaluationResults { + if ("results" in response) { + return { + ...response, + results: response.results.map((result) => ({ + ...result, + sourceRunId: undefined, + })), + }; + } + return { ...response, sourceRunId: undefined }; +} + +async function wrapSummaryEvaluators( + evaluators: SummaryEvaluatorT[], + optionsArray?: Partial[], + tracingEnabled?: boolean, +): Promise< + Array +> { + async function _wrap( + evaluator: SummaryEvaluatorT, + ): Promise { + const evalName = evaluator.name || "BatchEvaluator"; + + const wrapperInner = ( + runs: Run[], + examples: Example[], + ): Promise => { + const wrapperSuperInner = traceable( + ( + _runs_: string, + _examples_: string, + ): Promise< + EvaluationResult | EvaluationResult[] | EvaluationResults + > => { + // Check if the evaluator expects an object parameter + if (evaluator.length === 1) { + const inputs = examples.map((ex) => ex.inputs); + const outputs = runs.map((run) => run.outputs || {}); + const referenceOutputs = examples.map((ex) => ex.outputs || {}); + + return Promise.resolve( + ( + evaluator as (args: { + runs: Run[]; + examples: Example[]; + inputs: Record[]; + outputs: Record[]; + referenceOutputs?: Record[]; + }) => EvaluationResult | EvaluationResult[] | EvaluationResults + )({ + runs, + examples, + inputs, + outputs, + referenceOutputs, + }), + ); + } + // Otherwise use the traditional (runs, examples) signature + return Promise.resolve( + (evaluator as DeprecatedSyncSummaryEvaluator)(runs, examples), + ); + }, + { + ...optionsArray, + name: evalName, + ...(tracingEnabled === undefined ? {} : { tracingEnabled }), + }, + ); + + return Promise.resolve( + wrapperSuperInner( + `Runs[] (Length=${runs.length})`, + `Examples[] (Length=${examples.length})`, + ), + ); + }; + + return wrapperInner; + } + + const results: Array< + DeprecatedAsyncSummaryEvaluator | DeprecatedSyncSummaryEvaluator + > = []; + for (let i = 0; i < evaluators.length; i++) { + results.push(await _wrap(evaluators[i])); + } + return results; +} + +function _resolveEvaluators( + evaluators: Array, +): Array { + const results: Array = []; + for (const evaluator of evaluators) { + if ("evaluateRun" in evaluator) { + results.push(evaluator); + // todo fix this by porting LangChainStringEvaluator to langsmith sdk + } else if (evaluator.name === "LangChainStringEvaluator") { + throw new Error("Not yet implemented"); + } else { + results.push(runEvaluator(evaluator)); + } + } + return results; +} + +async function _resolveExperiment( + experiment: TracerSession | null, + runs: AsyncGenerator | null, + client: Client, +): Promise< + [TracerSession | string | undefined, AsyncGenerator | undefined] +> { + // TODO: Remove this, handle outside the manager + if (experiment !== null) { + if (!experiment.name) { + throw new Error("Experiment name must be defined if provided."); + } + return [experiment, undefined]; + } + + // If we have runs, that means the experiment was already started. + if (runs !== null) { + const results: AsyncGenerator[] = []; + for await (const item of atee(runs)) { + results.push(item); + } + const [runsClone, runsOriginal] = results; + const runsCloneIterator = runsClone[Symbol.asyncIterator](); + // todo: this is `any`. does it work properly? + const firstRun = await runsCloneIterator + .next() + .then((result) => result.value); + if (firstRun?.session_id == null) { + throw new Error("Experiment ID not found for provided runs."); + } + const retrievedExperiment = await client.readProject({ + projectId: firstRun.session_id, + }); + if (!retrievedExperiment.name) { + throw new Error("Experiment name not found for provided runs."); + } + return [retrievedExperiment, runsOriginal]; + } + + return [undefined, undefined]; +} + +/** + * Map over an iterable with bounded concurrency using p-queue. + * Results are yielded as soon as they resolve (input order is not preserved). + * The queue handles concurrency limits internally. + */ +export async function* _mapWithConcurrency( + iterable: Iterable | AsyncIterable, + queue: PQueueType, + mapper: (value: TInput) => Promise, +): AsyncGenerator { + type PendingTask = Promise<{ value: TOutput; self: PendingTask }>; + + const pending = new Set(); + + // Add all tasks to p-queue immediately (p-queue handles concurrency) + for await (const input of iterable) { + const task: PendingTask = queue + .add(() => mapper(input)) + .then((value) => ({ + value, + self: task, + })); + pending.add(task); + } + + // Yield results as they complete + while (pending.size > 0) { + const { value, self } = await Promise.race(pending); + pending.delete(self); + yield value; + } +} + +function _isCallable( + target: StandardTargetT | AsyncGenerator, +): target is StandardTargetT { + return Boolean( + typeof target === "function" || + ("invoke" in target && typeof target.invoke === "function"), + ); +} diff --git a/src/lib/evaluation/evaluate_comparative.ts b/src/lib/evaluation/evaluate_comparative.ts new file mode 100644 index 0000000..8af1127 --- /dev/null +++ b/src/lib/evaluation/evaluate_comparative.ts @@ -0,0 +1,431 @@ +import { v4 as uuid4, validate } from "../utils/uuid/src/index.js"; +import { Client } from "../index.js"; +import { + ComparativeExperiment, + ComparisonEvaluationResult as ComparisonEvaluationResultRow, + Example, + Run, + TracerSession, +} from "../schemas.js"; +import { shuffle } from "../utils/shuffle.js"; +import { AsyncCaller } from "../utils/async_caller.js"; +import { evaluate } from "./index.js"; +import pRetry from "../utils/p-retry/index.js"; +import { getCurrentRunTree, traceable } from "../traceable.js"; +import { loadTracesV2 } from "../utils/v2_migration.js"; + +type ExperimentResults = Awaited>; + +function isExperimentResultsList( + value: ExperimentResults[] | string[], +): value is ExperimentResults[] { + return value.some((x) => typeof x !== "string"); +} + +async function loadExperiment( + client: Client, + experiment: string | ExperimentResults, +) { + const value = + typeof experiment === "string" ? experiment : experiment.experimentName; + + return client.readProject( + validate(value) ? { projectId: value } : { projectName: value }, + ); +} + +export async function loadTracesForExperiment( + client: Client, + project: TracerSession, + options: { loadNested: boolean }, +): Promise { + // v1 `/runs/query` returns 501 on SmithDB-only backends; use v2 when it's available. + const isRoot = options.loadNested ? undefined : true; + const runs = (await client._supportsSDBQuery()) + ? await loadTracesV2(client, project, { isRoot }) + : // Via _listRuns() rather than listRuns(): evaluate() is supported, so + // callers have nothing to migrate; how it loads traces is an + // implementation detail. + await client._listRuns({ + projectId: project.id, + executionOrder: options.loadNested ? undefined : 1, + }); + + const treeMap: Record = {}; + const runIdMap: Record = {}; + const results: Run[] = []; + + for await (const run of runs) { + if (run.parent_run_id != null) { + treeMap[run.parent_run_id] ??= []; + treeMap[run.parent_run_id].push(run); + } else { + results.push(run); + } + runIdMap[run.id] = run; + } + + for (const [parentRunId, childRuns] of Object.entries(treeMap)) { + const parentRun = runIdMap[parentRunId]; + parentRun.child_runs = childRuns.sort((a, b) => { + if (a.dotted_order == null || b.dotted_order == null) return 0; + return a.dotted_order.localeCompare(b.dotted_order); + }); + } + + return results; +} + +/** @deprecated Use ComparativeEvaluatorNew instead: (args: { runs, example, inputs, outputs, referenceOutputs }) => ... */ +export type _ComparativeEvaluatorLegacy = ( + runs: Run[], + example: Example, +) => ComparisonEvaluationResultRow | Promise; + +export type _ComparativeEvaluator = (args: { + runs: Run[]; + example: Example; + inputs: Record; + outputs: Record[]; + referenceOutputs?: Record; +}) => ComparisonEvaluationResultRow | Promise; + +export type ComparativeEvaluator = + | _ComparativeEvaluatorLegacy + | _ComparativeEvaluator; + +export interface EvaluateComparativeOptions { + /** + * A list of evaluators to use for comparative evaluation. + */ + evaluators: Array; + /** + * Randomize the order of outputs for each evaluation + * @default false + */ + randomizeOrder?: boolean; + /** + * The LangSmith client to use. + * @default undefined + */ + client?: Client; + /** + * Metadata to attach to the experiment. + * @default undefined + */ + metadata?: Record; + /** + * A prefix to use for your experiment name. + * @default undefined + */ + experimentPrefix?: string; + /** + * A free-form description of the experiment. + * @default undefined + */ + description?: string; + /** + * Whether to load all child runs for the experiment. + * @default false + */ + loadNested?: boolean; + /** + * The maximum number of concurrent evaluators to run. + * @default undefined + */ + maxConcurrency?: number; +} + +export interface ComparisonEvaluationResults { + /** The name of the comparative experiment. */ + experimentName: string; + /** The per-example comparison results. */ + results: ComparisonEvaluationResultRow[]; + /** URL of the pairwise comparison view in the LangSmith UI, if available. */ + url: string | null; + /** The comparative experiment, exposing its id, dataset, and metadata. */ + comparativeExperiment: ComparativeExperiment; +} + +/** @deprecated Use `evaluate` and pass two experiments as targets. */ +export async function evaluateComparative( + experiments: + | Array + | Array | ExperimentResults>, + options: EvaluateComparativeOptions, +): Promise { + if (experiments.length < 2) { + throw new Error("Comparative evaluation requires at least 2 experiments."); + } + + if (!options.evaluators.length) { + throw new Error( + "At least one evaluator is required for comparative evaluation.", + ); + } + + if (options.maxConcurrency && options.maxConcurrency < 0) { + throw new Error("maxConcurrency must be a positive number."); + } + + const client = options.client ?? new Client(); + const resolvedExperiments = await Promise.all(experiments); + + const projects = await (() => { + if (!isExperimentResultsList(resolvedExperiments)) { + return Promise.all( + resolvedExperiments.map((experiment) => + loadExperiment(client, experiment), + ), + ); + } + + // if we know the number of runs beforehand, check if the + // number of runs in the project matches the expected number of runs + return Promise.all( + resolvedExperiments.map((experiment) => + pRetry( + async () => { + const project = await loadExperiment(client, experiment); + if (project.run_count !== experiment?.results.length) { + throw new Error("Experiment is missing runs. Retrying."); + } + return project; + }, + { factor: 2, minTimeout: 1000, retries: 10 }, + ), + ), + ); + })(); + + if (new Set(projects.map((p) => p.reference_dataset_id)).size > 1) { + throw new Error("All experiments must have the same reference dataset."); + } + + const referenceDatasetId = projects.at(0)?.reference_dataset_id; + if (!referenceDatasetId) { + throw new Error( + "Reference dataset is required for comparative evaluation.", + ); + } + + if ( + new Set(projects.map((p) => p.extra?.metadata?.dataset_version)).size > 1 + ) { + console.warn( + "Detected multiple dataset versions used by experiments, which may lead to inaccurate results.", + ); + } + + const datasetVersion = projects.at(0)?.extra?.metadata?.dataset_version; + + const id = uuid4(); + const experimentName = (() => { + if (!options.experimentPrefix) { + const names = projects + .map((p) => p.name) + .filter(Boolean) + .join(" vs. "); + return `${names}-${uuid4().slice(0, 4)}`; + } + + return `${options.experimentPrefix}-${uuid4().slice(0, 4)}`; + })(); + + // TODO: add URL to the comparative experiment + console.log(`Starting pairwise evaluation of: ${experimentName}`); + + const comparativeExperiment = await client.createComparativeExperiment({ + id, + name: experimentName, + experimentIds: projects.map((p) => p.id), + description: options.description, + metadata: options.metadata, + referenceDatasetId: projects.at(0)?.reference_dataset_id, + }); + + const viewUrl = await (async () => { + const projectId = projects.at(0)?.id ?? projects.at(1)?.id; + const datasetId = comparativeExperiment?.reference_dataset_id; + + if (projectId && datasetId) { + const hostUrl = (await client.getProjectUrl({ projectId })) + .split("/projects/p/") + .at(0); + + const result = new URL(`${hostUrl}/datasets/${datasetId}/compare`); + result.searchParams.set( + "selectedSessions", + projects.map((p) => p.id).join(","), + ); + + result.searchParams.set( + "comparativeExperiment", + comparativeExperiment.id, + ); + return result.toString(); + } + + return null; + })(); + + if (viewUrl != null) { + console.log(`View results at: ${viewUrl}`); + } + + const experimentRuns = await Promise.all( + projects.map((project) => + loadTracesForExperiment(client, project, { + loadNested: !!options.loadNested, + }), + ), + ); + + let exampleIdsIntersect: Set | undefined; + for (const runs of experimentRuns) { + const exampleIdsSet = new Set( + runs + .map((r) => r.reference_example_id) + .filter((x): x is string => x != null), + ); + + if (!exampleIdsIntersect) { + exampleIdsIntersect = exampleIdsSet; + } else { + exampleIdsIntersect = new Set( + [...exampleIdsIntersect].filter((x) => exampleIdsSet.has(x)), + ); + } + } + + const exampleIds = [...(exampleIdsIntersect ?? [])]; + if (!exampleIds.length) { + throw new Error("No examples found in common between experiments."); + } + + const exampleMap: Record = {}; + for (let start = 0; start < exampleIds.length; start += 99) { + const exampleIdsChunk = exampleIds.slice(start, start + 99); + for await (const example of client.listExamples({ + datasetId: referenceDatasetId, + exampleIds: exampleIdsChunk, + asOf: datasetVersion, + })) { + exampleMap[example.id] = example; + } + } + + const runMapByExampleId: Record = {}; + for (const runs of experimentRuns) { + for (const run of runs) { + if ( + run.reference_example_id == null || + !exampleIds.includes(run.reference_example_id) + ) { + continue; + } + + runMapByExampleId[run.reference_example_id] ??= []; + runMapByExampleId[run.reference_example_id].push(run); + } + } + + const caller = new AsyncCaller({ + maxConcurrency: options.maxConcurrency, + debug: client.debug, + }); + + async function evaluateAndSubmitFeedback( + runs: Run[], + example: Example, + evaluator: ComparativeEvaluator, + ) { + const expectedRunIds = new Set(runs.map((r) => r.id)); + // Check if evaluator expects an object parameter + const result = + evaluator.length === 1 + ? await (evaluator as _ComparativeEvaluator)({ + runs: options.randomizeOrder ? shuffle(runs) : runs, + example, + inputs: example.inputs, + outputs: runs.map((run) => run.outputs || {}), + referenceOutputs: example.outputs || {}, + }) + : await (evaluator as _ComparativeEvaluatorLegacy)(runs, example); + + // Build a lookup for run metadata + const runsById = new Map(runs.map((r) => [r.id, r])); + for (const [runId, score] of Object.entries(result.scores)) { + // validate if the run id + if (!expectedRunIds.has(runId)) { + throw new Error(`Returning an invalid run id ${runId} from evaluator.`); + } + const run = runsById.get(runId); + await client.createFeedback(runId, result.key, { + score, + sourceRunId: result.source_run_id, + comparativeExperimentId: comparativeExperiment.id, + sessionId: run?.session_id, + startTime: run?.start_time, + // `run` is the run being scored, so its trace is the right key. + traceId: run?.trace_id, + }); + } + + return result; + } + + const tracedEvaluators = options.evaluators.map((evaluator) => + traceable( + async ( + runs: Run[], + example: Example, + ): Promise => { + const evaluatorRun = getCurrentRunTree(); + const result = + evaluator.length === 1 + ? await (evaluator as _ComparativeEvaluator)({ + runs: options.randomizeOrder ? shuffle(runs) : runs, + example, + inputs: example.inputs, + outputs: runs.map((run) => run.outputs || {}), + referenceOutputs: example.outputs || {}, + }) + : await (evaluator as _ComparativeEvaluatorLegacy)(runs, example); + + // sanitise the payload before sending to LangSmith + evaluatorRun.inputs = { runs: runs, example: example }; + evaluatorRun.outputs = result; + + return { + ...result, + source_run_id: result.source_run_id ?? evaluatorRun.id, + }; + }, + { + project_name: "evaluators", + name: evaluator.name || "evaluator", + }, + ), + ); + + const promises = Object.entries(runMapByExampleId).flatMap( + ([exampleId, runs]) => { + const example = exampleMap[exampleId]; + if (!example) throw new Error(`Example ${exampleId} not found.`); + + return tracedEvaluators.map((evaluator) => + caller.call( + evaluateAndSubmitFeedback, + runs, + exampleMap[exampleId], + evaluator, + ), + ); + }, + ); + + const results: ComparisonEvaluationResultRow[] = await Promise.all(promises); + await client.awaitPendingTraceBatches(); + return { experimentName, results, url: viewUrl, comparativeExperiment }; +} diff --git a/src/lib/evaluation/evaluator.ts b/src/lib/evaluation/evaluator.ts new file mode 100644 index 0000000..2768945 --- /dev/null +++ b/src/lib/evaluation/evaluator.ts @@ -0,0 +1,275 @@ +import { + Example, + FeedbackConfig, + Run, + ScoreType, + ValueType, +} from "../schemas.js"; +import { v7 as uuidv7 } from "../utils/uuid/src/index.js"; +import { TraceableFunction, traceable } from "../traceable.js"; +import { RunTreeConfig } from "../run_trees.js"; + +/** + * Represents a categorical class. + */ +export type Category = { + /** + * The value of the category. + */ + value?: number; + /** + * The label of the category. + */ + label: string; +}; + +/** + * Represents the result of an evaluation. + */ +export type EvaluationResult = { + /** + * The key associated with the evaluation result. + */ + key: string; + /** + * The score of the evaluation result. + */ + score?: ScoreType; + /** + * The value of the evaluation result. + */ + value?: ValueType; + /** + * A comment associated with the evaluation result. + */ + comment?: string; + /** + * A correction record associated with the evaluation result. + */ + correction?: Record; + /** + * Information about the evaluator. + */ + evaluatorInfo?: Record; + /** + * The source run ID of the evaluation result. + * If set, a link to the source run will be available in the UI. + */ + sourceRunId?: string; + /** + * The target run ID of the evaluation result. + * If this is not set, the target run ID is assumed to be + * the root of the trace. + */ + targetRunId?: string; + + /** + * The feedback config associated with the evaluation result. + * If set, this will be used to define how a feedback key + * should be interpreted. + */ + feedbackConfig?: FeedbackConfig; +}; + +/** + * Batch evaluation results, if your evaluator wishes + * to return multiple scores. + */ +export type EvaluationResults = { + /** + * The evaluation results. + */ + results: Array; +}; + +export interface RunEvaluator { + evaluateRun( + run: Run, + example?: Example, + options?: Partial, + ): Promise; +} + +export type RunEvaluatorLike = + | (( + run: Run, + example?: Example, + ) => Promise) + | (( + run: Run, + example?: Example, + ) => EvaluationResult | EvaluationResult[] | EvaluationResults) + | (( + run: Run, + example: Example, + ) => Promise) + | (( + run: Run, + example: Example, + ) => EvaluationResult | EvaluationResult[] | EvaluationResults) + | ((args: { + run: Run; + example: Example; + inputs: Record; + outputs: Record; + referenceOutputs?: Record; + }) => EvaluationResult | EvaluationResult[] | EvaluationResults) + | ((args: { + run: Run; + example: Example; + inputs: Record; + outputs: Record; + referenceOutputs?: Record; + }) => Promise); + +/** + * Wraps an evaluator function + implements the RunEvaluator interface. + */ +export class DynamicRunEvaluator any> + implements RunEvaluator +{ + func: Func; + + constructor(evaluator: Func) { + this.func = ((input: { + langSmithRunAndExample: { run: Run; example: Example }; + }) => { + const { run, example } = input.langSmithRunAndExample; + + return evaluator( + { + ...run, + run, + example, + inputs: example?.inputs, + outputs: run?.outputs, + referenceOutputs: example?.outputs, + attachments: example?.attachments, + }, + example, + ); + }) as Func; + } + + private isEvaluationResults(x: unknown): x is EvaluationResults { + return ( + typeof x === "object" && + x != null && + "results" in x && + Array.isArray(x.results) && + x.results.length > 0 + ); + } + + private coerceEvaluationResults( + results: Record | EvaluationResults, + sourceRunId: string, + ): EvaluationResult | EvaluationResults { + if (this.isEvaluationResults(results)) { + return { + results: results.results.map((r) => + this.coerceEvaluationResult(r, sourceRunId, false), + ), + }; + } + + return this.coerceEvaluationResult( + results as Record, + sourceRunId, + true, + ); + } + + private coerceEvaluationResult( + result: EvaluationResult | Record, + sourceRunId: string, + allowNoKey = false, + ): EvaluationResult { + if ("key" in result) { + if (!result.sourceRunId) { + result.sourceRunId = sourceRunId; + } + return result as EvaluationResult; + } + + if (!("key" in result)) { + if (allowNoKey) { + result["key"] = this.func.name; + } + } + return { + sourceRunId, + ...result, + } as EvaluationResult; + } + + /** + * Evaluates a run with an optional example and returns the evaluation result. + * @param run The run to evaluate. + * @param example The optional example to use for evaluation. + * @returns A promise that extracts to the evaluation result. + */ + async evaluateRun( + run: Run, + example?: Example, + options?: Partial, + ): Promise { + let sourceRunId = uuidv7(); + const metadata: Record = { + targetRunId: run.id, + }; + if ("session_id" in run) { + metadata["experiment"] = run.session_id; + } + + if (typeof this.func !== "function") { + throw new Error("Target must be runnable function"); + } + + const wrappedTraceableFunc: TraceableFunction = traceable( + this.func, + { + project_name: "evaluators", + name: "evaluator", + on_end: (runTree) => { + // If tracing with OTEL, setting run id manually does not work. + // Instead get it at the end of the run. + sourceRunId = runTree.id; + }, + ...options, + }, + ); + + const result = await wrappedTraceableFunc( + // Pass data via `langSmithRunAndExample` key to avoid conflicts with other + // inputs. This key is extracted in the wrapped function, with `run` and + // `example` passed to evaluator function as arguments. + { langSmithRunAndExample: { run, example } }, + { metadata }, + ); + + // Check the one required property of EvaluationResult since 'instanceof' is not possible + if ("key" in result) { + if (!result.sourceRunId) { + result.sourceRunId = sourceRunId; + } + return result as EvaluationResult; + } + if (Array.isArray(result)) { + return { + results: result.map((r) => + this.coerceEvaluationResult(r, sourceRunId, false), + ), + }; + } + if (typeof result !== "object") { + throw new Error("Evaluator function must return an object."); + } + + return this.coerceEvaluationResults(result, sourceRunId); + } +} + +export function runEvaluator(func: RunEvaluatorLike): RunEvaluator { + return new DynamicRunEvaluator(func); +} diff --git a/src/lib/evaluation/index.ts b/src/lib/evaluation/index.ts new file mode 100644 index 0000000..3e4a723 --- /dev/null +++ b/src/lib/evaluation/index.ts @@ -0,0 +1,17 @@ +// Evaluation methods +export { RunEvaluator, EvaluationResult } from "./evaluator.js"; +export { + StringEvaluator, + GradingFunctionParams, + GradingFunctionResult, +} from "./string_evaluator.js"; +export { + evaluate, + type EvaluateOptions, + type TargetT, + type DataT, + type SummaryEvaluatorT, + type EvaluatorT, + type ExperimentResultRow, +} from "./_runner.js"; +export { evaluateComparative } from "./evaluate_comparative.js"; diff --git a/src/lib/evaluation/string_evaluator.ts b/src/lib/evaluation/string_evaluator.ts new file mode 100644 index 0000000..8c56791 --- /dev/null +++ b/src/lib/evaluation/string_evaluator.ts @@ -0,0 +1,69 @@ +import { Example, Run, ScoreType, ValueType } from "../schemas.js"; +import { EvaluationResult, RunEvaluator } from "./evaluator.js"; + +export interface GradingFunctionResult { + key?: string; + score?: ScoreType; + value?: ValueType; + comment?: string; + correction?: Record; +} + +export interface GradingFunctionParams { + input: string; + prediction: string; + answer?: string; +} + +export interface StringEvaluatorParams { + evaluationName?: string; + inputKey?: string; + predictionKey?: string; + answerKey?: string; + gradingFunction: ( + params: GradingFunctionParams, + ) => Promise; +} + +export class StringEvaluator implements RunEvaluator { + protected evaluationName?: string; + protected inputKey: string; + protected predictionKey: string; + protected answerKey?: string; + protected gradingFunction: ( + params: GradingFunctionParams, + ) => Promise; + + constructor(params: StringEvaluatorParams) { + this.evaluationName = params.evaluationName; + this.inputKey = params.inputKey ?? "input"; + this.predictionKey = params.predictionKey ?? "output"; + this.answerKey = + params.answerKey !== undefined ? params.answerKey : "output"; + this.gradingFunction = params.gradingFunction; + } + + async evaluateRun(run: Run, example?: Example): Promise { + if (!run.outputs) { + throw new Error("Run outputs cannot be undefined."); + } + const functionInputs = { + input: run.inputs[this.inputKey], + prediction: run.outputs[this.predictionKey], + answer: this.answerKey ? example?.outputs?.[this.answerKey] : null, + }; + + const gradingResults = await this.gradingFunction(functionInputs); + const key = gradingResults.key || this.evaluationName; + if (!key) { + throw new Error("Evaluation name cannot be undefined."); + } + return { + key, + score: gradingResults.score, + value: gradingResults.value, + comment: gradingResults.comment, + correction: gradingResults.correction, + }; + } +} diff --git a/src/lib/experimental/anthropic/context.ts b/src/lib/experimental/anthropic/context.ts new file mode 100644 index 0000000..b06c395 --- /dev/null +++ b/src/lib/experimental/anthropic/context.ts @@ -0,0 +1,656 @@ +import type { RunTree, RunTreeConfig } from "../../run_trees.js"; +import { + convertFromAnthropicMessage, + isTaskTool, + isToolBlock, + mergeMessagesById, +} from "./messages.js"; +import { getCurrentRunTree } from "../../traceable.js"; +import { + aggregateUsageFromModelUsage, + correctUsageFromResults, + extractUsageFromMessage, +} from "./usage.js"; +import { readTranscript, type TranscriptAssistantTurn } from "./transcripts.js"; +import type { SDKMessage, SDKResultMessage } from "./types.js"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function isToolResultError(value: unknown): boolean { + return isRecord(value) && (value.is_error === true || value.isError === true); +} + +function makeSubagentTranscriptPathKey( + path: string, + toolUseId?: string, + agentType?: string, +): string { + return JSON.stringify([path, toolUseId ?? null, agentType ?? null]); +} + +/** + * @internal + */ +export class StreamManager { + private static liveManagers: Set = new Set(); + private static managersByRootRun = new WeakMap(); + + private rootRun?: RunTree; + namespaces: { [namespace: string]: RunTree | undefined }; + history: { [namespace: string]: SDKMessage[] }; + + assistant: { [messageId: string]: RunTree | undefined } = {}; + tools: { [toolUseId: string]: RunTree | undefined } = {}; + subagents: { [toolUseId: string]: RunTree | undefined } = {}; + + mainTranscriptPath?: string; + subagentTranscriptPaths: { + path: string; + toolUseId?: string; + agentType?: string; + }[] = []; + private pendingAgentTools: Map> = new Map(); + private agentToToolUseId: Map = new Map(); + private transcriptPathKeys: Set = new Set(); + private resultModelUsage?: SDKResultMessage["modelUsage"]; + + postRunQueue: Promise[] = []; + runTrees: RunTree[] = []; + + constructor() { + const rootRun = getCurrentRunTree(true); + this.rootRun = rootRun; + this.namespaces = rootRun?.createChild ? { root: rootRun } : {}; + this.history = { root: [] }; + if (rootRun != null) { + StreamManager.managersByRootRun.set(rootRun, this); + } + StreamManager.liveManagers.add(this); + } + + dispose() { + StreamManager.liveManagers.delete(this); + if (this.rootRun != null) { + StreamManager.managersByRootRun.delete(this.rootRun); + } + } + + async addMessage(message: SDKMessage) { + const eventTime = Date.now(); + + // Short-circuit if no root run found + // This can happen if tracing is disabled globally + if (this.namespaces["root"] == null) return; + + if (message.type === "result") { + if (message.modelUsage) { + this.resultModelUsage = message.modelUsage; + } + + const usage = message.modelUsage + ? aggregateUsageFromModelUsage(message.modelUsage) + : extractUsageFromMessage(message); + + if (message.total_cost_usd != null && usage != null) { + usage.total_cost = message.total_cost_usd; + } + + this.namespaces["root"].extra ??= {}; + this.namespaces["root"].extra.metadata ??= {}; + // Keep the SDK's final aggregate usage available for inspection, but do not + // store it as `usage_metadata` on the parent chain. LangSmith rollups + // already aggregate child LLM usage, so parent `usage_metadata` would be + // counted again (notably doubling Anthropic cache-read/cache-write tokens). + this.namespaces["root"].extra.metadata.ls_aggregated_usage = usage; + + this.namespaces["root"].extra.metadata.is_error = message.is_error; + this.namespaces["root"].extra.metadata.num_turns = message.num_turns; + this.namespaces["root"].extra.metadata.session_id = message.session_id; + this.namespaces["root"].extra.metadata.thread_id = message.session_id; + this.namespaces["root"].extra.metadata.duration_ms = message.duration_ms; + this.namespaces["root"].extra.metadata.duration_api_ms = + message.duration_api_ms; + } + + // Skip non-user / non-assistant messages + if (!("message" in message)) return; + + const namespace = (() => { + if ("parent_tool_use_id" in message) + return message.parent_tool_use_id ?? "root"; + return "root"; + })(); + + // `eventTime` records the time we receive an event, which for `includePartialMessages: false` + // equals to the end time of an LLM block, so we need to use the first available end time within namespace. + const candidateStartTime = + this.namespaces[namespace]?.child_runs?.at(-1)?.end_time ?? + this.namespaces[namespace]?.start_time ?? + eventTime; + + this.history[namespace] ??= this.history["root"].slice(); + + if (message.type === "assistant") { + const messageId = message.message.id; + + this.assistant[messageId] ??= this.createChild(namespace, { + name: "claude.assistant.turn", + run_type: "llm", + start_time: candidateStartTime, + inputs: { + messages: convertFromAnthropicMessage(this.history[namespace]), + }, + outputs: { output: { messages: [] } }, + }); + + if (this.assistant[messageId] == null) return; + + this.assistant[messageId].outputs = (() => { + const prevMessages = + this.assistant[messageId].outputs?.output.messages ?? []; + const newMessages = convertFromAnthropicMessage([message]); + return { + output: { + messages: mergeMessagesById(prevMessages, newMessages), + }, + }; + })(); + + this.assistant[messageId].end_time = eventTime; + + this.assistant[messageId].extra ??= {}; + this.assistant[messageId].extra.metadata ??= {}; + + if (message.message.model != null) { + this.assistant[messageId].extra.metadata.ls_model_name = + message.message.model; + } + + this.assistant[messageId].extra.metadata.usage_metadata = + extractUsageFromMessage(message); + + const tools = Array.isArray(message.message.content) + ? message.message.content.filter((block) => isToolBlock(block)) + : []; + + for (const block of tools) { + if (isTaskTool(block)) { + this.createAgentToolRun(namespace, block, eventTime); + } else { + this.createToolRun(namespace, block, eventTime); + } + } + } + + if (message.type === "user") { + const toolResultBlocks = Array.isArray(message.message.content) + ? message.message.content.filter((block) => "tool_use_id" in block) + : []; + + const getToolOutput = (result: unknown) => { + if (isRecord(result)) { + return result; + } + + return { content: result }; + }; + + const getToolError = (result: unknown): string => { + if (["string", "number", "boolean"].includes(typeof result)) { + return String(result); + } + if (Array.isArray(result)) { + return result.map(getToolError).join("\n"); + } + if (isRecord(result)) { + if (typeof result.error === "string") return result.error; + if (typeof result.text === "string") return result.text; + if ("content" in result) return getToolError(result.content); + } + return JSON.stringify(result); + }; + + for (const block of toolResultBlocks) { + const tool = this.tools[block.tool_use_id]; + const subagent = this.subagents[block.tool_use_id]; + if (tool != null || subagent != null) { + // Previous versions of @anthropic-ai/claude-agent-sdk did provide + // tool result in `message.tool_use_result`, but at least since 0.2.50 it disappeared, + // so we rely on the last tool result block instead. + const result = + message.tool_use_result != null && toolResultBlocks.length === 1 + ? message.tool_use_result + : block.content; + + const toolOutput = getToolOutput(result); + + const isError = isToolResultError(block) || isToolResultError(result); + + const toolError = isError ? getToolError(result) : undefined; + + await tool?.end(toolOutput, toolError); + // Match Python's lifecycle: PostToolUse sets outputs on the + // subagent chain, but the subagent itself is not ended until after + // transcript reconciliation. Hidden transcript LLM/tool children can + // arrive after the Agent/Task tool result, so ending the chain here + // can make reconciled children appear outside their parent bounds. + if (subagent != null) { + subagent.outputs ??= toolOutput; + subagent.error ??= toolError; + } + } + } + } + + this.history[namespace].push(message); + } + + addHookEvent(input: unknown, toolUseId?: string) { + if (typeof input !== "object" || input == null) return; + const data = input as Record; + + if ( + this.mainTranscriptPath == null && + typeof data.transcript_path === "string" + ) { + this.mainTranscriptPath = data.transcript_path; + } + + if ( + data.hook_event_name === "PreToolUse" && + typeof toolUseId === "string" && + (data.tool_name === "Agent" || data.tool_name === "Task") + ) { + this.pendingAgentTools.set( + toolUseId, + typeof data.tool_input === "object" && data.tool_input != null + ? (data.tool_input as Record) + : {}, + ); + return; + } + + if (data.hook_event_name === "SubagentStart") { + const agentId = + typeof data.agent_id === "string" ? data.agent_id : undefined; + if (agentId == null) return; + + const agentType = + typeof data.agent_type === "string" ? data.agent_type : undefined; + let matchedToolUseId: string | undefined; + for (const [pendingToolUseId, toolInput] of this.pendingAgentTools) { + const pendingAgentType = + typeof toolInput.subagent_type === "string" + ? toolInput.subagent_type + : typeof toolInput.agent_type === "string" + ? toolInput.agent_type + : undefined; + if ( + agentType == null || + pendingAgentType == null || + pendingAgentType === agentType + ) { + matchedToolUseId = pendingToolUseId; + break; + } + } + matchedToolUseId ??= this.pendingAgentTools.keys().next().value; + if (matchedToolUseId != null) { + this.agentToToolUseId.set(agentId, matchedToolUseId); + this.pendingAgentTools.delete(matchedToolUseId); + } + return; + } + + if (data.hook_event_name === "SubagentStop") { + const transcriptPath = + typeof data.agent_transcript_path === "string" + ? data.agent_transcript_path + : undefined; + if (!transcriptPath) return; + + const agentId = + typeof data.agent_id === "string" ? data.agent_id : undefined; + const agentType = + typeof data.agent_type === "string" ? data.agent_type : undefined; + const mappedToolUseId = + agentId != null ? this.agentToToolUseId.get(agentId) : undefined; + if (agentId != null) this.agentToToolUseId.delete(agentId); + + this.addSubagentTranscriptPath( + transcriptPath, + mappedToolUseId, + agentType, + ); + } + } + + addSubagentTranscriptPath( + path: string, + toolUseId?: string, + agentType?: string, + ) { + const key = makeSubagentTranscriptPathKey(path, toolUseId, agentType); + if (this.transcriptPathKeys.has(key)) return; + this.transcriptPathKeys.add(key); + this.subagentTranscriptPaths.push({ path, toolUseId, agentType }); + } + + static getActiveToolRun( + toolName?: string, + input?: unknown, + ): RunTree | undefined { + const currentRun = getCurrentRunTree(true); + const currentManager = + currentRun != null + ? StreamManager.managersByRootRun.get(currentRun) + : undefined; + const currentRunTree = currentManager?.getActiveToolRun(toolName, input); + if (currentRunTree != null) return currentRunTree; + + // Last resort: the SDK invoked an MCP handler from a detached async context + // that did not inherit the existing LangSmith AsyncLocalStorage. Require + // both tool name and input to match before scanning live managers to avoid + // cross-query attribution. + if (toolName == null || input === undefined) return undefined; + + for (const manager of Array.from(StreamManager.liveManagers).reverse()) { + if (manager === currentManager) continue; + const runTree = manager.getActiveToolRun(toolName, input); + if (runTree != null) return runTree; + } + return undefined; + } + + private getActiveToolRun( + toolName?: string, + input?: unknown, + ): RunTree | undefined { + const toolEntries = Object.values(this.tools).filter( + (runTree): runTree is RunTree => + runTree != null && runTree.end_time == null && runTree.error == null, + ); + + return toolEntries.find((runTree) => { + if (toolName != null) { + const runName = String(runTree.name); + const nameMatches = + runName === toolName || + runName.includes(toolName) || + toolName.includes(runName); + if (!nameMatches) return false; + } + + if (input !== undefined) { + const recorded = runTree.inputs?.input ?? {}; + try { + return JSON.stringify(recorded) === JSON.stringify(input ?? {}); + } catch { + return false; + } + } + + return true; + }); + } + + protected createChild( + namespace: string, + args: Parameters[0], + ): RunTree | undefined { + const parentRunTree = this.namespaces[namespace]; + if (parentRunTree == null) return undefined; + return this.createChildRun(parentRunTree, args); + } + + private createChildRun( + parentRunTree: RunTree, + args: Parameters[0], + ): RunTree | undefined { + const runTree = parentRunTree.createChild(args); + if (runTree == null) return undefined; + + this.postRunQueue.push(runTree.postRun()); + this.runTrees.push(runTree); + return runTree; + } + + private getAgentName(block: { input: Record }): string { + const subagentType = block.input.subagent_type; + if (typeof subagentType === "string" && subagentType.length > 0) { + return subagentType; + } + + const agentType = block.input.agent_type; + if (typeof agentType === "string" && agentType.length > 0) { + return agentType; + } + + const description = block.input.description; + if (typeof description === "string" && description.length > 0) { + return description.split(" ")[0] || "unknown-agent"; + } + + return "unknown-agent"; + } + + private createToolRun( + namespace: string, + block: Record, + startTime?: number, + ): RunTree | undefined { + if (typeof block.id !== "string") return undefined; + + const name = typeof block.name === "string" ? block.name : "unknown-tool"; + this.tools[block.id] ??= + this.createChild(namespace, { + name, + run_type: "tool", + inputs: block.input ? { input: block.input } : {}, + start_time: startTime, + }) ?? this.tools[block.id]; + return this.tools[block.id]; + } + + private createAgentToolRun( + namespace: string, + block: Record, + startTime?: number, + ) { + if (typeof block.id !== "string") return; + if ( + typeof block.input !== "object" || + block.input == null || + Array.isArray(block.input) + ) { + return; + } + + const input = block.input as Record; + const agentToolRun = this.createToolRun(namespace, block, startTime); + if (agentToolRun == null) return; + + this.subagents[block.id] ??= + this.createChildRun(agentToolRun, { + name: this.getAgentName({ input }), + run_type: "chain", + inputs: input, + start_time: startTime, + extra: { + metadata: { + ls_agent_type: "subagent", + }, + }, + }) ?? this.subagents[block.id]; + + this.namespaces[block.id] ??= this.subagents[block.id]; + } + + private resolveSubagentNamespace(agentType?: string): string | undefined { + const entries = Object.entries(this.namespaces); + if (agentType == null) return undefined; + return entries.find(([, runTree]) => runTree?.name === agentType)?.[0]; + } + + private createSyntheticAssistantRun( + namespace: string, + turn: TranscriptAssistantTurn, + ) { + let runTree = this.assistant[turn.messageId]; + + if (runTree == null) { + runTree = this.createChild(namespace, { + name: "claude.assistant.turn", + run_type: "llm", + start_time: turn.timestamp, + inputs: + turn.inputMessages.length > 0 ? { messages: turn.inputMessages } : {}, + outputs: { + output: { messages: convertFromAnthropicMessage(turn.message) }, + }, + extra: { + metadata: { + ls_provider: "anthropic", + ...(turn.model != null ? { ls_model_name: turn.model } : {}), + ...(turn.usageMetadata != null + ? { usage_metadata: turn.usageMetadata } + : {}), + }, + }, + }); + if (runTree == null) return; + this.assistant[turn.messageId] = runTree; + } else { + runTree.outputs = { + output: { messages: convertFromAnthropicMessage(turn.message) }, + }; + runTree.extra ??= {}; + runTree.extra.metadata ??= {}; + if (turn.model != null) runTree.extra.metadata.ls_model_name = turn.model; + if (turn.usageMetadata != null) { + runTree.extra.metadata.usage_metadata = turn.usageMetadata; + } + } + + runTree.end_time = turn.timestamp; + + const tools = Array.isArray(turn.message.message.content) + ? turn.message.message.content.filter((block) => isToolBlock(block)) + : []; + + for (const block of tools) { + if (isTaskTool(block)) { + this.createAgentToolRun(namespace, block, turn.timestamp); + } else { + this.createToolRun(namespace, block, turn.timestamp); + } + } + } + + private async reconcileTranscripts() { + const usageByMessageId: Record> = {}; + + if (this.mainTranscriptPath != null) { + const transcript = await readTranscript(this.mainTranscriptPath); + Object.assign(usageByMessageId, transcript.usageByMessageId); + } + + for (const transcriptPath of this.subagentTranscriptPaths) { + const namespace = + transcriptPath.toolUseId ?? + this.resolveSubagentNamespace(transcriptPath.agentType); + if (namespace == null || this.namespaces[namespace] == null) continue; + + const transcript = await readTranscript(transcriptPath.path); + Object.assign(usageByMessageId, transcript.usageByMessageId); + + for (const turn of transcript.turns) { + this.createSyntheticAssistantRun(namespace, turn); + } + + for (const toolResult of transcript.toolResults) { + const tool = this.tools[toolResult.toolUseId]; + if (tool == null) continue; + const output = isRecord(toolResult.content) + ? toolResult.content + : { content: toolResult.content }; + const error = toolResult.isError + ? typeof toolResult.content === "string" || + typeof toolResult.content === "number" || + typeof toolResult.content === "boolean" + ? String(toolResult.content) + : JSON.stringify(toolResult.content) + : undefined; + await tool.end(output, error); + } + } + + for (const [messageId, usage] of Object.entries(usageByMessageId)) { + const runTree = this.assistant[messageId]; + if (runTree == null) continue; + runTree.extra ??= {}; + runTree.extra.metadata ??= {}; + runTree.extra.metadata.usage_metadata = usage; + } + } + + async finish() { + try { + await this.reconcileTranscripts(); + + if (this.resultModelUsage != null) { + correctUsageFromResults( + this.resultModelUsage, + Object.values(this.assistant).filter((runTree) => runTree != null), + ); + } + + // Clean up incomplete tools and finalise subagent calls. This mirrors the + // Python integration: Agent/Task tool runs are ended when their tool result + // arrives, while subagent chain runs are finalised only after transcript + // reconciliation so hidden child LLM/tool runs are created first. + for (const tool of Object.values(this.tools)) { + if (tool == null) continue; + if (tool.outputs == null && tool.error == null) { + await tool.end(undefined, "Run not completed (conversation ended)"); + } + } + + for (const subagent of Object.values(this.subagents)) { + if (subagent == null) continue; + if (subagent.end_time == null) { + if (subagent.outputs == null && subagent.error == null) { + await subagent.end( + undefined, + "Run not completed (conversation ended)", + ); + } else { + await subagent.end(); + } + } + } + + // First make sure all the runs are created + await Promise.allSettled(this.postRunQueue); + + // Then patch the runs + await Promise.allSettled( + this.runTrees.map((runTree) => runTree.patchRun()), + ); + } finally { + this.dispose(); + } + } +} + +/** + * Configuration options for wrapping Claude Agent SDK with LangSmith tracing. + */ +export type WrapClaudeAgentSDKConfig = Partial< + Omit< + RunTreeConfig, + "inputs" | "outputs" | "run_type" | "child_runs" | "parent_run" | "error" + > +>; diff --git a/src/lib/experimental/anthropic/index.ts b/src/lib/experimental/anthropic/index.ts new file mode 100644 index 0000000..38c7b5e --- /dev/null +++ b/src/lib/experimental/anthropic/index.ts @@ -0,0 +1,391 @@ +import { + traceable, + isTraceableFunction, + withRunTree, +} from "../../traceable.js"; +import { StreamManager, type WrapClaudeAgentSDKConfig } from "./context.js"; +import { convertFromAnthropicMessage, mergeMessagesById } from "./messages.js"; +import type { SDKMessage, SDKUserMessage, QueryOptions } from "./types.js"; + +const WRAPPED_TOOL_SYMBOL = Symbol.for( + "langsmith:claude_agent_sdk:wrapped_tool", +); +const WRAPPED_TOOL_HANDLER_SYMBOL = Symbol.for( + "langsmith:claude_agent_sdk:wrapped_tool_handler", +); + +/** + * Wraps the Claude Agent SDK's query function to add LangSmith tracing. + * Traces the entire agent interaction including all streaming messages. + * @internal Use `wrapClaudeAgentSDK` instead. + */ +function wrapClaudeAgentQuery< + T extends (...args: unknown[]) => AsyncGenerator, +>(queryFn: T, defaultThis?: unknown, baseConfig?: WrapClaudeAgentSDKConfig): T { + async function* generator( + originalGenerator: AsyncGenerator, + prompt: string | AsyncIterable | undefined, + streamManager: StreamManager, + ) { + try { + let systemCount = 0; + for await (const message of originalGenerator) { + if (message.type === "system") { + const content = getLatestInput(prompt, systemCount); + systemCount += 1; + + if (content != null) await streamManager.addMessage(content); + } + + await streamManager.addMessage(message); + yield message; + } + } finally { + await streamManager.finish(); + } + } + + function getLatestInput( + arg: string | AsyncIterable | undefined, + systemCount: number, + ): SDKUserMessage | undefined { + const value = (() => { + if (typeof arg !== "object" || arg == null) return arg; + + const toJSON = (arg as unknown as Record)["toJSON"]; + if (typeof toJSON !== "function") return undefined; + const latest = toJSON(); + return latest?.at(systemCount); + })(); + + if (value == null) return undefined; + if (typeof value === "string") { + return { + type: "user" as const, + message: { content: value, role: "user" }, + parent_tool_use_id: null, + session_id: "", + }; + } + + return typeof value === "object" && value != null ? value : undefined; + } + + async function processInputs(rawInputs: unknown) { + const inputs = rawInputs as { + prompt: string | AsyncIterable; + options: QueryOptions; + }; + + const newInputs = { ...inputs } as { + prompt: string | AsyncIterable; + options: Record; + }; + + return Object.assign(newInputs, { + toJSON: () => { + const toJSON = (value: unknown) => { + if (typeof value !== "object" || value == null) return value; + const fn = (value as Record)?.toJSON; + if (typeof fn === "function") return fn(); + return value; + }; + + const prompt = toJSON(inputs.prompt) as + | string + | Iterable + | undefined; + + const options: Record | undefined = + inputs.options != null + ? ({ ...inputs.options } as QueryOptions) + : undefined; + + if (options?.mcpServers != null) { + options.mcpServers = Object.fromEntries( + Object.entries(options.mcpServers ?? {}).map(([key, value]) => [ + key, + { name: value.name, type: value.type }, + ]), + ); + } + + return { messages: convertFromAnthropicMessage(prompt), options }; + }, + }); + } + + function addLangSmithHooks( + params: { + prompt: string | AsyncIterable; + options?: QueryOptions; + }, + streamManager: StreamManager, + ) { + const options = { ...(params.options ?? {}) } as Record; + const hooks = { ...((options.hooks as Record) ?? {}) }; + + const addHook = (eventName: string) => { + const existing = Array.isArray(hooks[eventName]) + ? (hooks[eventName] as unknown[]) + : []; + hooks[eventName] = [ + ...existing, + { + hooks: [ + async (input: unknown, toolUseId?: string) => { + streamManager.addHookEvent(input, toolUseId); + return {}; + }, + ], + }, + ]; + }; + + addHook("PreToolUse"); + addHook("SubagentStart"); + addHook("SubagentStop"); + addHook("Stop"); + options.hooks = hooks; + + return { ...params, options }; + } + + function processOutputs(rawOutputs: Record) { + if ("outputs" in rawOutputs && Array.isArray(rawOutputs.outputs)) { + const sdkMessages = rawOutputs.outputs as SDKMessage[]; + const messages = sdkMessages + .filter((message) => { + if (!("message" in message)) return true; + return message.parent_tool_use_id == null; + }) + .flatMap(convertFromAnthropicMessage) + .reduce( + (acc, message) => mergeMessagesById(acc, [message]), + [] as ReturnType, + ); + + return { output: { messages } }; + } + return rawOutputs; + } + + return traceable( + ( + params: { + prompt: string | AsyncIterable; + options: QueryOptions; + }, + ...args: unknown[] + ) => { + const streamManager = new StreamManager(); + const paramsWithHooks = addLangSmithHooks(params, streamManager); + const actualGenerator = queryFn.call( + defaultThis, + paramsWithHooks, + ...args, + ); + const wrappedGenerator = generator( + actualGenerator, + params.prompt, + streamManager, + ); + + for (const method of Object.getOwnPropertyNames( + Object.getPrototypeOf(actualGenerator), + ).filter( + (method) => + !["constructor", "next", "throw", "return"].includes(method), + )) { + Object.defineProperty(wrappedGenerator, method, { + get() { + const getValue = + actualGenerator?.[method as keyof typeof actualGenerator]; + if (typeof getValue === "function") + return getValue.bind(actualGenerator); + return getValue; + }, + }); + } + + return wrappedGenerator; + }, + { + name: "claude.conversation", + run_type: "chain", + ...baseConfig, + metadata: { + ls_integration: "claude-agent-sdk-js", + ls_agent_type: "root", + ...baseConfig?.metadata, + }, + __deferredSerializableArgOptions: { maxDepth: 1 }, + processInputs, + processOutputs, + }, + ) as unknown as T; +} + +/** + * Wraps the Claude Agent SDK with LangSmith tracing. This returns wrapped versions + * of query and tool that automatically trace all agent interactions. + * + * @param sdk - The Claude Agent SDK module + * @param config - Optional LangSmith configuration + * @returns Object with wrapped query, tool, and createSdkMcpServer functions + * + * @example + * ```typescript + * import * as claudeSDK from "@anthropic-ai/claude-agent-sdk"; + * import { wrapClaudeAgentSDK } from "langsmith/experimental/claude_agent_sdk"; + * + * // Wrap once - returns { query, tool, createSdkMcpServer } with tracing built-in + * const { query, tool, createSdkMcpServer } = wrapClaudeAgentSDK(claudeSDK); + * + * // Use normally - tracing is automatic + * for await (const message of query({ + * prompt: "Hello, Claude!", + * options: { model: "claude-haiku-4-5-20251001" } + * })) { + * console.log(message); + * } + * + * // Tools created with wrapped tool() are automatically traced + * const calculator = tool("calculator", "Does math", schema, handler); + * ``` + */ +export function wrapClaudeAgentSDK( + sdk: T, + config?: WrapClaudeAgentSDKConfig, +): T { + type TypedSdk = T & { + query?: (...args: unknown[]) => AsyncGenerator; + tool?: (...args: unknown[]) => unknown; + createSdkMcpServer?: () => unknown; + + unstable_v2_createSession?: (...args: unknown[]) => unknown; + unstable_v2_prompt?: (...args: unknown[]) => unknown; + unstable_v2_resumeSession?: (...args: unknown[]) => unknown; + }; + + const inputSdk = sdk as TypedSdk; + const wrappedSdk = { ...sdk } as TypedSdk; + + const toolAlreadyWrapped = + "tool" in inputSdk && + typeof inputSdk.tool === "function" && + WRAPPED_TOOL_SYMBOL in inputSdk.tool; + + if ( + ("query" in inputSdk && isTraceableFunction(inputSdk.query)) || + toolAlreadyWrapped + ) { + throw new Error( + "This instance of Claude Agent SDK has been already wrapped by `wrapClaudeAgentSDK`.", + ); + } + + // Wrap the query method if it exists + if ("query" in inputSdk && typeof inputSdk.query === "function") { + wrappedSdk.query = wrapClaudeAgentQuery(inputSdk.query, inputSdk, config); + } + + // Wrap the tool method if it exists. The SDK invokes MCP tool handlers in an + // async context that may not inherit the query trace context, so bind nested + // traceable calls back to the active tool run when possible. + if ("tool" in inputSdk && typeof inputSdk.tool === "function") { + wrappedSdk.tool = (...args: unknown[]) => { + const toolName = typeof args[0] === "string" ? args[0] : undefined; + let handlerIndex = -1; + for (let index = args.length - 1; index >= 0; index -= 1) { + if (typeof args[index] === "function") { + handlerIndex = index; + break; + } + } + if (handlerIndex < 0) { + return inputSdk.tool?.(...args); + } + + const originalHandler = args[handlerIndex] as ( + ...handlerArgs: unknown[] + ) => unknown; + if (WRAPPED_TOOL_HANDLER_SYMBOL in originalHandler) { + return inputSdk.tool?.(...args); + } + + const wrappedHandler = (...handlerArgs: unknown[]) => { + const activeToolRun = StreamManager.getActiveToolRun( + toolName, + handlerArgs[0], + ); + if (activeToolRun == null) { + return originalHandler(...handlerArgs); + } + return withRunTree(activeToolRun, () => + originalHandler(...handlerArgs), + ); + }; + Object.defineProperty(wrappedHandler, WRAPPED_TOOL_HANDLER_SYMBOL, { + value: true, + }); + + const wrappedArgs = [...args]; + wrappedArgs[handlerIndex] = wrappedHandler; + return inputSdk.tool?.(...wrappedArgs); + }; + Object.defineProperty(wrappedSdk.tool, WRAPPED_TOOL_SYMBOL, { + value: true, + }); + } + + // Keep createSdkMcpServer and other methods as-is (bound to original SDK) + if ( + "createSdkMcpServer" in inputSdk && + typeof inputSdk.createSdkMcpServer === "function" + ) { + wrappedSdk.createSdkMcpServer = inputSdk.createSdkMcpServer.bind(inputSdk); + } + + if ( + "unstable_v2_createSession" in inputSdk && + typeof inputSdk.unstable_v2_createSession === "function" + ) { + wrappedSdk.unstable_v2_createSession = ( + ...args: Parameters + ) => { + console.warn( + "Tracing of `unstable_v2_createSession` is not supported by LangSmith. Tracing will not be applied.", + ); + return inputSdk.unstable_v2_createSession?.(...args); + }; + } + if ( + "unstable_v2_prompt" in inputSdk && + typeof inputSdk.unstable_v2_prompt === "function" + ) { + wrappedSdk.unstable_v2_prompt = ( + ...args: Parameters + ) => { + console.warn( + "Tracing of `unstable_v2_prompt` is not supported by LangSmith. Tracing will not be applied.", + ); + return inputSdk.unstable_v2_prompt?.(...args); + }; + } + if ( + "unstable_v2_resumeSession" in inputSdk && + typeof inputSdk.unstable_v2_resumeSession === "function" + ) { + wrappedSdk.unstable_v2_resumeSession = ( + ...args: Parameters + ) => { + console.warn( + "Tracing of `unstable_v2_resumeSession` is not supported by LangSmith. Tracing will not be applied.", + ); + return inputSdk.unstable_v2_resumeSession?.(...args); + }; + } + + return wrappedSdk as T; +} diff --git a/src/lib/experimental/anthropic/messages.ts b/src/lib/experimental/anthropic/messages.ts new file mode 100644 index 0000000..503abc1 --- /dev/null +++ b/src/lib/experimental/anthropic/messages.ts @@ -0,0 +1,205 @@ +import type { + BetaContentBlock, + BetaToolUseBlock, + SDKMessage, +} from "./types.js"; +import { isIterable } from "./utils.js"; + +export type LangSmithMessage = { + content?: unknown; + role?: string; + id?: string; +} & Record; + +function getContentBlockKey(block: unknown): string | undefined { + if (typeof block !== "object" || block == null) return undefined; + const record = block as Record; + if (typeof record.id === "string") return `id:${record.id}`; + if (typeof record.signature === "string") + return `signature:${record.signature}`; + if (typeof record.type === "string" && typeof record.text === "string") { + return `text:${record.text}`; + } + try { + return `json:${JSON.stringify(record)}`; + } catch { + return undefined; + } +} + +function mergeContentBlocks(existing: unknown, incoming: unknown): unknown { + if (!Array.isArray(existing) || !Array.isArray(incoming)) return incoming; + + const merged = [...existing]; + const keys = new Set(merged.map(getContentBlockKey).filter(Boolean)); + + for (const block of incoming) { + const key = getContentBlockKey(block); + if (key != null && keys.has(key)) continue; + merged.push(block); + if (key != null) keys.add(key); + } + + return merged; +} + +export function mergeMessagesById( + previousMessages: LangSmithMessage[], + newMessages: LangSmithMessage[], +): LangSmithMessage[] { + const merged = [...previousMessages]; + + for (const message of newMessages) { + const existingIndex = merged.findIndex( + (prev) => + prev.role === message.role && + message.id != null && + prev.id === message.id, + ); + + if (existingIndex < 0) { + merged.push(message); + continue; + } + + const existing = merged[existingIndex]; + merged[existingIndex] = { + ...existing, + ...message, + content: mergeContentBlocks(existing.content, message.content), + }; + } + + return merged; +} + +/** + * Converts SDK content blocks into serializable objects. + * Matches Python's flatten_content_blocks behavior. + */ +export function flattenContentBlocks( + content: BetaContentBlock[] | unknown, +): Array> | unknown { + if (!Array.isArray(content)) { + return content; + } + + return content.map((block) => { + if (!block || typeof block !== "object" || !("type" in block)) { + return block; + } + + const blockType = block.type; + + switch (blockType) { + case "text": + return { type: "text", text: block.text || "" }; + case "thinking": + return { + type: "thinking", + thinking: block.thinking || "", + signature: block.signature || "", + }; + case "tool_use": + return { + type: "tool_use", + id: block.id, + name: block.name, + input: block.input, + }; + case "tool_result": + return { + type: "tool_result", + tool_use_id: block.tool_use_id, + content: block.content, + is_error: block.is_error || false, + }; + default: + return block; + } + }); +} + +/** + * Utility function to convert an Anthropic message to a LangSmith-compatible message. + * @internal + */ +export function convertFromAnthropicMessage( + sdkMessage: SDKMessage | Iterable | string | undefined, +): LangSmithMessage[] { + if (sdkMessage == null) return []; + if (typeof sdkMessage === "string") { + return [{ content: sdkMessage, role: "user" }]; + } + + if (isIterable(sdkMessage)) { + return Array.from(sdkMessage).flatMap(convertFromAnthropicMessage); + } + + if ( + typeof sdkMessage !== "object" || + sdkMessage == null || + !("message" in sdkMessage) || + (sdkMessage.type !== "assistant" && sdkMessage.type !== "user") + ) { + return []; + } + + const { role = sdkMessage.type, content, ...rest } = sdkMessage.message; + const flattened = flattenContentBlocks(content); + + const toolResultBlocks = + role === "user" && Array.isArray(flattened) + ? flattened.filter(isToolResultBlock) + : []; + + if (toolResultBlocks.length > 0) { + return toolResultBlocks.map((block) => ({ ...block, role: "tool" })); + } + + return [{ ...rest, content: flattened, role }]; +} + +function isToolResultBlock(block: unknown): block is { + type: "tool_result"; + tool_use_id: string; + content: string; + is_error: boolean; +} { + if (typeof block !== "object" || block == null) return false; + if (!("type" in block)) return false; + return block.type === "tool_result"; +} + +/** + * Type assertion to check if a tool is a Task tool + * @param tool - The tool to check + * @returns True if the tool is a Task tool, false otherwise + * @internal + */ +export function isTaskTool(tool: BetaToolUseBlock): tool is { + id: string; + input: { + description: string; + prompt: string; + subagent_type: string; + agent_type?: string; + }; + name: "Task"; + type: "tool_use"; +} { + return ( + tool.type === "tool_use" && (tool.name === "Task" || tool.name === "Agent") + ); +} + +/** + * Type-assertion to check for tool blocks + * @internal + */ +export function isToolBlock( + block: BetaContentBlock, +): block is BetaToolUseBlock { + if (!block || typeof block !== "object") return false; + return block.type === "tool_use"; +} diff --git a/src/lib/experimental/anthropic/transcripts.ts b/src/lib/experimental/anthropic/transcripts.ts new file mode 100644 index 0000000..b155118 --- /dev/null +++ b/src/lib/experimental/anthropic/transcripts.ts @@ -0,0 +1,188 @@ +import { readFile } from "node:fs/promises"; + +import { + convertFromAnthropicMessage, + type LangSmithMessage, +} from "./messages.js"; +import type { SDKAssistantMessage, SDKUserMessage } from "./types.js"; +import { extractUsageMetadata } from "./usage.js"; + +export type TranscriptAssistantTurn = { + messageId: string; + model?: string; + content: Record[]; + usage?: Record; + usageMetadata?: Record; + timestamp?: number; + inputMessages: LangSmithMessage[]; + message: SDKAssistantMessage; +}; + +export type TranscriptToolResult = { + toolUseId: string; + content: unknown; + isError?: boolean; +}; + +export type TranscriptData = { + turns: TranscriptAssistantTurn[]; + usageByMessageId: Record>; + toolResults: TranscriptToolResult[]; +}; + +function parseTimestamp(value: unknown): number | undefined { + if (typeof value !== "string" || value.length === 0) return undefined; + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? undefined : timestamp; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +function toSDKAssistantMessage( + data: Record, +): SDKAssistantMessage | undefined { + const message = data.message; + if (!isRecord(message)) return undefined; + + const id = message.id; + if (typeof id !== "string" || id.length === 0) return undefined; + + return { + type: "assistant", + parent_tool_use_id: + typeof data.parent_tool_use_id === "string" + ? data.parent_tool_use_id + : null, + message: { + ...(message as SDKAssistantMessage["message"]), + id, + content: Array.isArray(message.content) + ? (message.content as Record[]) + : [], + }, + }; +} + +function toSDKUserMessage( + data: Record, +): SDKUserMessage | undefined { + const message = data.message; + if (!isRecord(message)) return undefined; + + return { + type: "user", + parent_tool_use_id: + typeof data.parent_tool_use_id === "string" + ? data.parent_tool_use_id + : null, + session_id: typeof data.session_id === "string" ? data.session_id : "", + message: message as SDKUserMessage["message"], + tool_use_result: data.tool_use_result, + }; +} + +/** + * Read a Claude Agent SDK JSONL transcript and return final assistant turns, + * final usage by message id, and tool_result blocks. + * + * The transcript format is not a contracted SDK API. This mirrors the Python + * reconciler and is intentionally best-effort: malformed lines and unreadable + * files are ignored so tracing never affects the user conversation. + * @internal + */ +export async function readTranscript( + filePath: string, +): Promise { + let contents: string; + try { + contents = await readFile(filePath, "utf8"); + } catch { + return { turns: [], usageByMessageId: {}, toolResults: [] }; + } + + const entriesById = new Map(); + const usageByMessageId: Record> = {}; + const toolResults: TranscriptToolResult[] = []; + const conversation: LangSmithMessage[] = []; + + for (const line of contents.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let data: unknown; + try { + data = JSON.parse(trimmed); + } catch { + continue; + } + if (!isRecord(data)) continue; + + if (data.type === "user") { + const sdkMessage = toSDKUserMessage(data); + if (sdkMessage == null) continue; + + const content = sdkMessage.message.content; + const toolUseResultIsError = isRecord(sdkMessage.tool_use_result) + ? sdkMessage.tool_use_result.is_error === true || + sdkMessage.tool_use_result.isError === true + : false; + if (Array.isArray(content)) { + for (const block of content) { + if (!isRecord(block) || block.type !== "tool_result") continue; + const toolUseId = block.tool_use_id; + if (typeof toolUseId !== "string") continue; + toolResults.push({ + toolUseId, + content: block.content, + isError: + block.is_error === true || + block.isError === true || + toolUseResultIsError, + }); + } + } + + conversation.push(...convertFromAnthropicMessage(sdkMessage)); + continue; + } + + if (data.type !== "assistant") continue; + + const sdkMessage = toSDKAssistantMessage(data); + if (sdkMessage == null) continue; + + const messageId = sdkMessage.message.id; + const rawUsage = sdkMessage.message.usage; + if (rawUsage != null) { + usageByMessageId[messageId] = extractUsageMetadata(rawUsage); + } + + const turn: TranscriptAssistantTurn = { + messageId, + model: sdkMessage.message.model, + content: sdkMessage.message.content, + usage: rawUsage, + usageMetadata: + rawUsage != null ? extractUsageMetadata(rawUsage) : undefined, + timestamp: parseTimestamp(data.timestamp), + inputMessages: conversation.slice(), + message: sdkMessage, + }; + // Always overwrite. The final chunk (where stop_reason is set) appears last. + entriesById.set(messageId, turn); + + if (sdkMessage.message.stop_reason) { + conversation.push(...convertFromAnthropicMessage(sdkMessage)); + } + } + + return { + turns: Array.from(entriesById.values()).filter( + (turn) => turn.message.message.stop_reason, + ), + usageByMessageId, + toolResults, + }; +} diff --git a/src/lib/experimental/anthropic/types.ts b/src/lib/experimental/anthropic/types.ts new file mode 100644 index 0000000..c30ec45 --- /dev/null +++ b/src/lib/experimental/anthropic/types.ts @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +// export type { +// SDKUserMessage, +// SDKResultMessage, +// SDKMessage, +// ModelUsage, +// Options as QueryOptions, +// } from "@anthropic-ai/claude-agent-sdk"; + +// import type Anthropic from "@anthropic-ai/sdk"; +// export type BetaContentBlock = Anthropic.Beta.BetaContentBlock; +// export type BetaToolUseBlock = Anthropic.Beta.BetaToolUseBlock; + +// Mock types to avoid importing the actual types when consuming +// Use actual types above when developing +export type SDKAssistantMessage = { + type: "assistant"; + message: { + id: string; + role?: string; + content: Record[]; + usage?: Record; + model?: string; + stop_reason?: string | null; + }; + parent_tool_use_id: string | null; +}; +export type SDKSystemMessage = { + type: "system"; +}; + +export type SDKUserMessage = { + type: "user"; + message: { + role?: string; + content: Record | Record[] | string; + usage?: Record; + model?: string; + }; + session_id: string; + tool_use_result?: unknown; + parent_tool_use_id: string | null; +}; +export type SDKResultMessage = { + type: "result"; + modelUsage: ModelUsage; + total_cost_usd: number | null; + is_error: boolean | null; + num_turns: number | null; + session_id: string | null; + duration_ms: number | null; + duration_api_ms: number | null; + usage: Record; +}; +export type SDKMessage = + | SDKAssistantMessage + | SDKUserMessage + | SDKSystemMessage + | SDKResultMessage; +export type ModelUsage = { [key: string]: any }; +export type QueryOptions = { [key: string]: any }; + +export type BetaContentBlock = { [key: string]: any }; +export type BetaToolUseBlock = { [key: string]: any }; diff --git a/src/lib/experimental/anthropic/usage.ts b/src/lib/experimental/anthropic/usage.ts new file mode 100644 index 0000000..a1f9d28 --- /dev/null +++ b/src/lib/experimental/anthropic/usage.ts @@ -0,0 +1,253 @@ +import { convertAnthropicUsageToInputTokenDetails } from "../../utils/usage.js"; +import { getNumberProperty } from "./utils.js"; +import type { RunTree } from "../../run_trees.js"; +import type { ModelUsage, SDKMessage, SDKResultMessage } from "./types.js"; + +/** + * Aggregates usage from modelUsage breakdown (includes all models, including hidden ones). + * This provides accurate totals when multiple models are used. + * @internal + */ +export function aggregateUsageFromModelUsage( + modelUsage: Record, +): Record { + const metrics: Record = {}; + + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheReadTokens = 0; + let totalCacheCreationTokens = 0; + + // Aggregate across all models + for (const modelStats of Object.values(modelUsage)) { + totalInputTokens += modelStats.inputTokens || 0; + totalOutputTokens += modelStats.outputTokens || 0; + totalCacheReadTokens += modelStats.cacheReadInputTokens || 0; + totalCacheCreationTokens += modelStats.cacheCreationInputTokens || 0; + } + + // Build input_token_details if we have cache tokens + if (totalCacheReadTokens > 0 || totalCacheCreationTokens > 0) { + metrics.input_token_details = { + cache_read: totalCacheReadTokens, + cache_creation: totalCacheCreationTokens, + }; + } + + // Sum all input tokens (new + cache read + cache creation) + const totalPromptTokens = + totalInputTokens + totalCacheReadTokens + totalCacheCreationTokens; + + metrics.input_tokens = totalPromptTokens; + metrics.output_tokens = totalOutputTokens; + metrics.total_tokens = totalPromptTokens + totalOutputTokens; + + return metrics; +} + +/** + * Extracts and normalizes usage metrics from a Claude Agent SDK message. + * @internal + */ +export function extractUsageMetadata(usage: unknown): Record { + const metrics: Record = {}; + + if (!usage || typeof usage !== "object") { + return metrics; + } + + // Standard token counts - use LangSmith's expected field names + const inputTokens = getNumberProperty(usage, "input_tokens") || 0; + const outputTokens = getNumberProperty(usage, "output_tokens") || 0; + + const inputTokenDetails = convertAnthropicUsageToInputTokenDetails( + usage as Record, + ); + const cacheTokens = Object.values(inputTokenDetails).reduce( + (sum, value) => sum + (typeof value === "number" ? value : 0), + 0, + ); + + if (Object.keys(inputTokenDetails).length > 0 && cacheTokens > 0) { + metrics.input_token_details = inputTokenDetails; + } + + // Sum cache tokens into input_tokens total (matching Python's sum_anthropic_tokens) + const totalInputTokens = inputTokens + cacheTokens; + + metrics.input_tokens = totalInputTokens; + metrics.output_tokens = outputTokens; + metrics.total_tokens = totalInputTokens + outputTokens; + + return metrics; +} + +export function extractUsageFromMessage( + message: SDKMessage, +): Record { + // Assistant messages contain usage in message.message.usage + // Result messages contain usage in message.usage + let usage: unknown; + if (message.type === "assistant") { + usage = message.message?.usage; + } else if (message.type === "result") { + usage = message.usage; + } + + return extractUsageMetadata(usage); +} + +/** + * Corrects usage metrics for assistant runs based on the results of the runs. + * @internal + */ +export function correctUsageFromResults( + resultUsages: SDKResultMessage["modelUsage"], + assistantRuns: RunTree[], +) { + const runByModel = assistantRuns.reduce( + (acc, run) => { + const modelId = run.extra?.metadata?.ls_model_name; + if (!modelId) return acc; + acc[modelId] ??= []; + acc[modelId].push(run); + return acc; + }, + {} as { [modelId: string]: RunTree[] }, + ); + + const runUsageByModel = assistantRuns.reduce( + (acc, run) => { + const modelId = run.extra?.metadata?.ls_model_name; + if (!modelId) return acc; + + const usageMetadata = { ...run.extra?.metadata?.usage_metadata }; + + usageMetadata.input_tokens ??= 0; + usageMetadata.output_tokens ??= 0; + usageMetadata.total_tokens ??= 0; + usageMetadata.input_token_details = { + ...usageMetadata.input_token_details, + }; + usageMetadata.input_token_details.cache_read ??= 0; + usageMetadata.input_token_details.ephemeral_5m_input_tokens ??= 0; + usageMetadata.input_token_details.ephemeral_1h_input_tokens ??= 0; + + acc[modelId] ??= { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + input_token_details: { + cache_read: 0, + cache_creation: 0, + }, + }; + + acc[modelId].input_tokens += usageMetadata.input_tokens; + acc[modelId].output_tokens += usageMetadata.output_tokens; + acc[modelId].total_tokens += usageMetadata.total_tokens; + acc[modelId].input_token_details.cache_read += + usageMetadata.input_token_details.cache_read; + acc[modelId].input_token_details.cache_creation += + usageMetadata.input_token_details.ephemeral_5m_input_tokens; + acc[modelId].input_token_details.cache_creation += + usageMetadata.input_token_details.ephemeral_1h_input_tokens; + + return acc; + }, + {} as { + [modelId: string]: { + input_tokens: number; + output_tokens: number; + total_tokens: number; + input_token_details: { + cache_read: number; + cache_creation: number; + }; + }; + }, + ); + + const resultUsageMap = Object.fromEntries( + Object.entries(resultUsages).map(([modelId, usage]) => [ + modelId, + { + input_tokens: + usage.inputTokens + + usage.cacheReadInputTokens + + usage.cacheCreationInputTokens, + output_tokens: usage.outputTokens, + total_tokens: + usage.inputTokens + + usage.cacheReadInputTokens + + usage.cacheCreationInputTokens + + usage.outputTokens, + input_token_details: { + cache_read: usage.cacheReadInputTokens, + cache_creation: usage.cacheCreationInputTokens, + }, + }, + ]), + ); + + for (const modelId in resultUsageMap) { + const lastRun = runByModel[modelId]?.at(-1); + const runsUsage = runUsageByModel[modelId]; + + const resultUsage = resultUsageMap[modelId]; + if (!runsUsage || !lastRun) continue; + + const difference = { + input_tokens: Math.max( + 0, + resultUsage.input_tokens - runsUsage.input_tokens, + ), + output_tokens: Math.max( + 0, + resultUsage.output_tokens - runsUsage.output_tokens, + ), + total_tokens: Math.max( + 0, + resultUsage.total_tokens - runsUsage.total_tokens, + ), + cache_read: Math.max( + 0, + resultUsage.input_token_details.cache_read - + runsUsage.input_token_details.cache_read, + ), + cache_creation: Math.max( + 0, + resultUsage.input_token_details.cache_creation - + runsUsage.input_token_details.cache_creation, + ), + }; + + if (Object.values(difference).some((value) => value > 0)) { + // apply difference to the last run + lastRun.extra ??= {}; + lastRun.extra.metadata ??= {}; + lastRun.extra.metadata.usage_metadata ??= {}; + + lastRun.extra.metadata.usage_metadata.input_tokens ??= 0; + lastRun.extra.metadata.usage_metadata.input_tokens += + difference.input_tokens; + + lastRun.extra.metadata.usage_metadata.output_tokens ??= 0; + lastRun.extra.metadata.usage_metadata.output_tokens += + difference.output_tokens; + + lastRun.extra.metadata.usage_metadata.total_tokens ??= 0; + lastRun.extra.metadata.usage_metadata.total_tokens += + difference.total_tokens; + + lastRun.extra.metadata.usage_metadata.input_token_details ??= {}; + lastRun.extra.metadata.usage_metadata.input_token_details.cache_read ??= 0; + lastRun.extra.metadata.usage_metadata.input_token_details.cache_read += + difference.cache_read; + + lastRun.extra.metadata.usage_metadata.input_token_details.ephemeral_5m_input_tokens ??= 0; + lastRun.extra.metadata.usage_metadata.input_token_details.ephemeral_5m_input_tokens += + difference.cache_creation; + } + } +} diff --git a/src/lib/experimental/anthropic/utils.ts b/src/lib/experimental/anthropic/utils.ts new file mode 100644 index 0000000..ae242ef --- /dev/null +++ b/src/lib/experimental/anthropic/utils.ts @@ -0,0 +1,28 @@ +/** + * Gets a number property from an object. + * @internal + */ +export function getNumberProperty( + obj: unknown, + key: string, +): number | undefined { + if (!obj || typeof obj !== "object" || !(key in obj)) { + return undefined; + } + const value = Reflect.get(obj, key); + return typeof value === "number" ? value : undefined; +} + +/** + * Checks if a value is iterable. + * @internal + */ +export function isIterable(value: unknown): value is Iterable { + return ( + typeof value === "object" && + value !== null && + typeof (value as Record)[ + Symbol.iterator + ] === "function" + ); +} diff --git a/src/lib/experimental/otel/constants.ts b/src/lib/experimental/otel/constants.ts new file mode 100644 index 0000000..4c54c16 --- /dev/null +++ b/src/lib/experimental/otel/constants.ts @@ -0,0 +1,71 @@ +// OpenTelemetry GenAI semantic convention attribute names +export const GEN_AI_OPERATION_NAME = "gen_ai.operation.name"; +export const GEN_AI_SYSTEM = "gen_ai.system"; +export const GEN_AI_REQUEST_MODEL = "gen_ai.request.model"; +export const GEN_AI_RESPONSE_MODEL = "gen_ai.response.model"; +export const GEN_AI_USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"; +export const GEN_AI_USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"; +export const GEN_AI_USAGE_TOTAL_TOKENS = "gen_ai.usage.total_tokens"; +export const GEN_AI_REQUEST_MAX_TOKENS = "gen_ai.request.max_tokens"; +export const GEN_AI_REQUEST_TEMPERATURE = "gen_ai.request.temperature"; +export const GEN_AI_REQUEST_TOP_P = "gen_ai.request.top_p"; +export const GEN_AI_REQUEST_FREQUENCY_PENALTY = + "gen_ai.request.frequency_penalty"; +export const GEN_AI_REQUEST_PRESENCE_PENALTY = + "gen_ai.request.presence_penalty"; +export const GEN_AI_RESPONSE_FINISH_REASONS = "gen_ai.response.finish_reasons"; +export const GENAI_PROMPT = "gen_ai.prompt"; +export const GENAI_COMPLETION = "gen_ai.completion"; + +export const GEN_AI_REQUEST_EXTRA_QUERY = "gen_ai.request.extra_query"; +export const GEN_AI_REQUEST_EXTRA_BODY = "gen_ai.request.extra_body"; +export const GEN_AI_SERIALIZED_NAME = "gen_ai.serialized.name"; +export const GEN_AI_SERIALIZED_SIGNATURE = "gen_ai.serialized.signature"; +export const GEN_AI_SERIALIZED_DOC = "gen_ai.serialized.doc"; +export const GEN_AI_RESPONSE_ID = "gen_ai.response.id"; +export const GEN_AI_TOOL_NAME = "gen_ai.tool.name"; +export const GEN_AI_TOOL_CALL_ID = "gen_ai.tool.call.id"; +export const GEN_AI_TOOL_DEFINITIONS = "gen_ai.tool.definitions"; +export const GEN_AI_RESPONSE_SERVICE_TIER = "gen_ai.response.service_tier"; +export const GEN_AI_RESPONSE_SYSTEM_FINGERPRINT = + "gen_ai.response.system_fingerprint"; +export const GEN_AI_USAGE_INPUT_TOKEN_DETAILS = + "gen_ai.usage.input_token_details"; +export const GEN_AI_USAGE_OUTPUT_TOKEN_DETAILS = + "gen_ai.usage.output_token_details"; + +// LangSmith custom attributes +export const LANGSMITH_SESSION_ID = "langsmith.trace.session_id"; +export const LANGSMITH_SESSION_NAME = "langsmith.trace.session_name"; +export const LANGSMITH_RUN_TYPE = "langsmith.span.kind"; +export const LANGSMITH_NAME = "langsmith.trace.name"; +export const LANGSMITH_METADATA = "langsmith.metadata"; +export const LANGSMITH_TAGS = "langsmith.span.tags"; +export const LANGSMITH_RUNTIME = "langsmith.span.runtime"; +export const LANGSMITH_REQUEST_STREAMING = "langsmith.request.streaming"; +export const LANGSMITH_REQUEST_HEADERS = "langsmith.request.headers"; +export const LANGSMITH_RUN_ID = "langsmith.span.id"; +export const LANGSMITH_TRACE_ID = "langsmith.trace.id"; +export const LANGSMITH_DOTTED_ORDER = "langsmith.span.dotted_order"; +export const LANGSMITH_PARENT_RUN_ID = "langsmith.span.parent_id"; +export const LANGSMITH_USAGE_METADATA = "langsmith.usage_metadata"; +export const LANGSMITH_REFERENCE_EXAMPLE_ID = "langsmith.reference_example_id"; +export const LANGSMITH_TRACEABLE = "langsmith.traceable"; +export const LANGSMITH_IS_ROOT = "langsmith.is_root"; +export const LANGSMITH_TRACEABLE_PARENT_OTEL_SPAN_ID = + "langsmith.traceable_parent_otel_span_id"; + +// GenAI event names +export const GEN_AI_SYSTEM_MESSAGE = "gen_ai.system.message"; +export const GEN_AI_USER_MESSAGE = "gen_ai.user.message"; +export const GEN_AI_ASSISTANT_MESSAGE = "gen_ai.assistant.message"; +export const GEN_AI_CHOICE = "gen_ai.choice"; + +export const AI_SDK_LLM_OPERATIONS = [ + "ai.generateText.doGenerate", + "ai.streamText.doStream", + "ai.generateObject.doGenerate", + "ai.streamObject.doStream", +]; + +export const AI_SDK_TOOL_OPERATIONS = ["ai.toolCall"]; diff --git a/src/lib/experimental/otel/exporter.ts b/src/lib/experimental/otel/exporter.ts new file mode 100644 index 0000000..ef0e432 --- /dev/null +++ b/src/lib/experimental/otel/exporter.ts @@ -0,0 +1,249 @@ +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; +import { ReadableSpan } from "@opentelemetry/sdk-trace-base"; +import * as constants from "./constants.js"; +import { isEnvTracingEnabled } from "../../env.js"; +import { + getEnvironmentVariable, + getLangSmithEnvironmentVariable, +} from "../../utils/env.js"; +import { extractUsageMetadata } from "../../utils/vercel.js"; + +/** + * Convert headers string in format "name=value,name2=value2" to object + */ +function parseHeadersString(headersStr: string): Record { + const headers: Record = {}; + if (!headersStr) return headers; + + headersStr.split(",").forEach((pair) => { + const [name, ...valueParts] = pair.split("="); + if (name && valueParts.length > 0) { + headers[name.trim()] = valueParts.join("=").trim(); + } + }); + + return headers; +} + +export type LangSmithOTLPTraceExporterConfig = ConstructorParameters< + typeof OTLPTraceExporter +>[0] & { + /** + * A function that takes an exported span and returns a transformed version of it. + * May be used to add or remove attributes from the span. + * + * For example, to add a custom attribute to the span, you can do: + * + * ```ts + * import { LangSmithOTLPTraceExporter } from "langsmith/experimental/otel/exporter"; + * + * const exporter = new LangSmithOTLPTraceExporter({ + * transformExportedSpan: (span) => { + * if (span.name === "foo") { + * span.attributes["langsmith.metadata.bar"] = "baz"; + * } + * return span; + * } + * }); + * ``` + * + * @param span - The span to transform. + * @returns A transformed version of the span. + */ + transformExportedSpan?: ( + span: ReadableSpan, + ) => ReadableSpan | Promise; + + /** + * The API key to use for the exporter. + */ + apiKey?: string; + + /** + * The name of the project to export traces to. + */ + projectName?: string; + + /** + * Default headers to add to exporter requests. + */ + headers?: Record; +}; + +/** + * LangSmith OpenTelemetry trace exporter that extends the standard OTLP trace exporter + * with LangSmith-specific configuration and span attribute transformations. + * + * This exporter automatically configures itself with LangSmith endpoints and API keys, + * based on your LANGSMITH_API_KEY and LANGSMITH_PROJECT environment variables. + * Will also respect OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_HEADERS environment + * variables if set. + * + * @param config - Optional configuration object that accepts all OTLPTraceExporter parameters. + * If not provided, uses default LangSmith configuration: + * - `url`: Defaults to LangSmith OTEL endpoint (`${LANGSMITH_ENDPOINT}/otel/v1/traces`) + * - `headers`: Auto-configured with LangSmith API key and project headers + * Any provided config will override these defaults. + */ +export class LangSmithOTLPTraceExporter extends OTLPTraceExporter { + private transformExportedSpan?: ( + span: ReadableSpan, + ) => ReadableSpan | Promise; + + private projectName?: string; + + constructor(config?: LangSmithOTLPTraceExporterConfig) { + const defaultLsEndpoint = + getLangSmithEnvironmentVariable("ENDPOINT") || + "https://api.smith.langchain.com"; + const defaultBaseUrl = defaultLsEndpoint.replace(/\/$/, ""); + const defaultUrl = + getEnvironmentVariable("OTEL_EXPORTER_OTLP_ENDPOINT") ?? + `${defaultBaseUrl}/otel/v1/traces`; + // Configure headers with API key and project if available + let headers = config?.headers; + if (headers === undefined) { + let defaultHeaderString = + getEnvironmentVariable("OTEL_EXPORTER_OTLP_HEADERS") ?? ""; + if (!defaultHeaderString) { + const apiKey = + config?.apiKey ?? getLangSmithEnvironmentVariable("API_KEY"); + if (apiKey) { + defaultHeaderString = `x-api-key=${apiKey}`; + } + } + headers = parseHeadersString(defaultHeaderString); + } + + super({ + url: defaultUrl, + headers, + ...config, + }); + + this.transformExportedSpan = config?.transformExportedSpan; + this.projectName = + config?.projectName ?? getLangSmithEnvironmentVariable("PROJECT"); + } + + export( + spans: ReadableSpan[], + resultCallback: Parameters[1], + ): void { + if (!isEnvTracingEnabled()) { + return resultCallback({ code: 0 }); + } + const runExport = async () => { + for (let span of spans) { + if (this.transformExportedSpan) { + span = await this.transformExportedSpan(span); + } + if (!span.attributes[constants.GENAI_PROMPT]) { + if (span.attributes["ai.prompt"]) { + span.attributes[constants.GENAI_PROMPT] = + span.attributes["ai.prompt"]; + } + if ( + span.attributes["ai.prompt.messages"] && + typeof span.attributes["ai.prompt.messages"] === "string" + ) { + let messages; + try { + messages = JSON.parse(span.attributes["ai.prompt.messages"]); + } catch (e) { + console.error("Failed to parse ai.prompt.messages", e); + } + if (messages && Array.isArray(messages)) { + span.attributes[constants.GENAI_PROMPT] = JSON.stringify({ + input: messages, + }); + } + } + if (span.attributes["ai.toolCall.input"]) { + span.attributes[constants.GENAI_PROMPT] = + span.attributes["ai.toolCall.input"]; + } else if (span.attributes["ai.toolCall.args"]) { + span.attributes[constants.GENAI_PROMPT] = + span.attributes["ai.toolCall.args"]; + } + } + // Iterate over all attributes starting with "ai.telemetry.metadata" + for (const [key, value] of Object.entries(span.attributes)) { + if (key.startsWith("ai.telemetry.metadata.")) { + if (key === "ai.telemetry.metadata.ls_project_name") { + span.attributes[constants.LANGSMITH_SESSION_NAME] = value; + } else if (key === "ai.telemetry.metadata.ls_project_id") { + span.attributes[constants.LANGSMITH_SESSION_ID] = value; + } else { + const metadataKey = key.replace("ai.telemetry.metadata.", ""); + span.attributes[ + `${constants.LANGSMITH_METADATA}.${metadataKey}` + ] = value; + } + delete span.attributes[key]; + } + } + if (!span.attributes[constants.GENAI_COMPLETION]) { + if (span.attributes["ai.response.text"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.response.text"]; + } + if (span.attributes["ai.response.choices"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.response.choices"]; + } + if (span.attributes["ai.response.object"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.response.object"]; + } + if (span.attributes["ai.response.toolCalls"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.response.toolCalls"]; + } + if (span.attributes["ai.toolCall.output"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.toolCall.output"]; + } else if (span.attributes["ai.toolCall.result"]) { + span.attributes[constants.GENAI_COMPLETION] = + span.attributes["ai.toolCall.result"]; + } + } + if ( + typeof span.attributes["ai.operationId"] === "string" && + constants.AI_SDK_LLM_OPERATIONS.includes( + span.attributes["ai.operationId"], + ) + ) { + span.attributes[constants.LANGSMITH_RUN_TYPE] = "llm"; + const usageMetadata = extractUsageMetadata(span); + span.attributes[constants.LANGSMITH_USAGE_METADATA] = + JSON.stringify(usageMetadata); + } else if ( + typeof span.attributes["ai.operationId"] === "string" && + constants.AI_SDK_TOOL_OPERATIONS.includes( + span.attributes["ai.operationId"], + ) + ) { + span.attributes[constants.LANGSMITH_RUN_TYPE] = "tool"; + if (span.attributes["ai.toolCall.name"]) { + span.attributes[constants.LANGSMITH_NAME] = + span.attributes["ai.toolCall.name"]; + } + } + if (span.attributes[`${constants.LANGSMITH_METADATA}.ls_run_name`]) { + span.attributes[constants.LANGSMITH_NAME] = + span.attributes[`${constants.LANGSMITH_METADATA}.ls_run_name`]; + delete span.attributes[`${constants.LANGSMITH_METADATA}.ls_run_name`]; + } + if ( + span.attributes[constants.LANGSMITH_SESSION_NAME] === undefined && + this.projectName !== undefined + ) { + span.attributes[constants.LANGSMITH_SESSION_NAME] = this.projectName; + } + } + super.export(spans, resultCallback); + }; + void runExport(); + } +} diff --git a/src/lib/experimental/otel/processor.ts b/src/lib/experimental/otel/processor.ts new file mode 100644 index 0000000..d1e4fa4 --- /dev/null +++ b/src/lib/experimental/otel/processor.ts @@ -0,0 +1,111 @@ +import { + ReadableSpan, + Span, + BatchSpanProcessor, +} from "@opentelemetry/sdk-trace-base"; +import { Context } from "@opentelemetry/api"; +import { + LANGSMITH_IS_ROOT, + LANGSMITH_PARENT_RUN_ID, + LANGSMITH_TRACEABLE, + LANGSMITH_TRACEABLE_PARENT_OTEL_SPAN_ID, +} from "./constants.js"; +import { getUuidFromOtelSpanId } from "./utils.js"; +import { RunTree } from "../../run_trees.js"; + +export function isTraceableSpan(span: ReadableSpan): boolean { + return ( + span.attributes[LANGSMITH_TRACEABLE] === "true" || + typeof span.attributes["ai.operationId"] === "string" + ); +} + +function getParentSpanId(span: ReadableSpan): string | undefined { + // Backcompat shim to support OTEL 1.x and 2.x + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ( + (span as any).parentSpanId ?? span.parentSpanContext?.spanId ?? undefined + ); +} + +type TraceInfo = { + spanInfo: Record< + string, + { isTraceable: boolean; parentSpanId: string | undefined } + >; + spanCount: number; +}; + +/** + * Span processor that filters out spans that are not LangSmith-related and + * usually should not be traced. + */ +export class LangSmithOTLPSpanProcessor extends BatchSpanProcessor { + private traceMap: Record = {}; + + onStart(span: Span, parentContext: Context): void { + if (!this.traceMap[span.spanContext().traceId]) { + this.traceMap[span.spanContext().traceId] = { + spanInfo: {}, + spanCount: 0, + }; + } + this.traceMap[span.spanContext().traceId].spanCount++; + const isTraceable = isTraceableSpan(span); + const parentSpanId = getParentSpanId(span); + this.traceMap[span.spanContext().traceId].spanInfo[ + span.spanContext().spanId + ] = { + isTraceable, + parentSpanId, + }; + + let currentCandidateParentSpanId = parentSpanId; + let traceableParentId; + while (currentCandidateParentSpanId) { + const currentSpanInfo = + this.traceMap[span.spanContext().traceId].spanInfo[ + currentCandidateParentSpanId + ]; + if (currentSpanInfo?.isTraceable) { + traceableParentId = currentCandidateParentSpanId; + break; + } + currentCandidateParentSpanId = currentSpanInfo?.parentSpanId; + } + if (!traceableParentId) { + span.attributes[LANGSMITH_IS_ROOT] = true; + } else { + span.attributes[LANGSMITH_PARENT_RUN_ID] = + getUuidFromOtelSpanId(traceableParentId); + span.attributes[LANGSMITH_TRACEABLE_PARENT_OTEL_SPAN_ID] = + traceableParentId; + } + if (isTraceable) { + super.onStart(span, parentContext); + } + } + + onEnd(span: ReadableSpan): void { + const traceInfo = this.traceMap[span.spanContext().traceId]; + if (!traceInfo) return; + + const spanInfo = traceInfo.spanInfo[span.spanContext().spanId]; + if (!spanInfo) return; + + // Decrement span count and cleanup trace if all spans are done + traceInfo.spanCount--; + if (traceInfo.spanCount <= 0) { + delete this.traceMap[span.spanContext().traceId]; + } + + if (spanInfo.isTraceable) { + super.onEnd(span); + } + } + + async shutdown() { + await RunTree.getSharedClient().awaitPendingTraceBatches(); + await super.shutdown(); + } +} diff --git a/src/lib/experimental/otel/setup.ts b/src/lib/experimental/otel/setup.ts new file mode 100644 index 0000000..5e2469b --- /dev/null +++ b/src/lib/experimental/otel/setup.ts @@ -0,0 +1,150 @@ +// Avoid async hooks as even a peer dep to avoid pulling into +// non-node build environments. +import { AsyncHooksContextManager } from "@opentelemetry/context-async-hooks"; +import { + trace as otel_trace, + context as otel_context, + type TracerProvider, + type ContextManager, +} from "@opentelemetry/api"; +import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; +import { + LangSmithOTLPTraceExporter, + LangSmithOTLPTraceExporterConfig, +} from "./exporter.js"; +import { LangSmithOTLPSpanProcessor } from "./processor.js"; + +import { + setDefaultOTLPTracerComponents, + setOTELInstances, +} from "../../singletons/otel.js"; + +/** + * Configuration options for initializing OpenTelemetry with LangSmith. + */ +export type InitializeOTELConfig = { + /** + * Optional custom OTEL TracerProvider to use instead of + * creating and globally setting a new one. + */ + globalTracerProvider?: TracerProvider; + /** + * Optional custom OTEL ContextManager to use instead of + * creating and globally setting a new one with AsyncHooksContextManager. + */ + globalContextManager?: ContextManager; + /** + * Skip automatic initialization of a global context manager. + * + * @default false + */ + skipGlobalContextManagerSetup?: boolean; + /** + * Optional configuration passed to the default LangSmith OTLP trace exporter. + */ + exporterConfig?: LangSmithOTLPTraceExporterConfig; +}; + +/** + * @deprecated Use non-OTEL `wrapAISDK` from `langsmith/experimental/vercel` instead. + * + * Initializes OpenTelemetry with LangSmith-specific configuration for tracing. + * + * Call this once at the start of your application to enable tracing integration. Sets global + * OpenTelemetry components including the tracer provider and context manager. + * + * Requires the following peer dependencies to be installed: + * - @opentelemetry/api + * - @opentelemetry/sdk-trace-base + * - @opentelemetry/exporter-trace-otlp-proto + * - @opentelemetry/context-async-hooks + * + * @param options - Configuration options + * @param options.globalTracerProvider - Optional custom TracerProvider to use instead of creating and globally setting a new one + * @returns Object containing the initialized OTEL components (tracer provider, span processor, exporter) + * + * @example + * ```typescript + * import { initializeOTEL } from "langsmith/experimental/otel/setup"; + * initializeOTEL(); + * ``` + * + * @example With custom tracer provider + * ```typescript + * import { initializeOTEL } from "langsmith/experimental/otel/setup"; + * import { BasicTracerProvider } from "@opentelemetry/sdk-trace-base"; + * + * const customProvider = new BasicTracerProvider(); + * initializeOTEL({ globalTracerProvider: customProvider }); + * ``` + */ +export const initializeOTEL = (config: InitializeOTELConfig = {}) => { + const { + globalTracerProvider, + globalContextManager, + skipGlobalContextManagerSetup, + exporterConfig, + } = config; + + const otel = { + trace: otel_trace, + context: otel_context, + }; + + setOTELInstances(otel); + + if (!globalContextManager && !skipGlobalContextManagerSetup) { + try { + const contextManager = new AsyncHooksContextManager(); + contextManager.enable(); + otel_context.setGlobalContextManager(contextManager); + } catch (_e) { + console.log( + [ + `Could not automatically set up an OTEL context manager.`, + `This may be expected if you have (or another imported library has) already set a global context manager.`, + `If expected, you can skip this warning by passing "skipGlobalContextManagerSetup: true" into your initializeOTEL call.`, + ].join("\n"), + ); + } + } + + const DEFAULT_LANGSMITH_SPAN_EXPORTER = new LangSmithOTLPTraceExporter( + exporterConfig, + ); + + const DEFAULT_LANGSMITH_SPAN_PROCESSOR = new LangSmithOTLPSpanProcessor( + DEFAULT_LANGSMITH_SPAN_EXPORTER, + ); + + if (!globalTracerProvider) { + const DEFAULT_LANGSMITH_TRACER_PROVIDER = new BasicTracerProvider({ + spanProcessors: [DEFAULT_LANGSMITH_SPAN_PROCESSOR], + }); + + const defaultComponents = { + DEFAULT_LANGSMITH_SPAN_PROCESSOR, + DEFAULT_LANGSMITH_TRACER_PROVIDER, + DEFAULT_LANGSMITH_SPAN_EXPORTER, + }; + + // If user has set global tracer before, this fails and returns false + const globalSuccessfullyOverridden = otel_trace.setGlobalTracerProvider( + defaultComponents.DEFAULT_LANGSMITH_TRACER_PROVIDER, + ); + + if (globalSuccessfullyOverridden) { + setDefaultOTLPTracerComponents(defaultComponents); + } + + return defaultComponents; + } else { + const defaultComponents = { + DEFAULT_LANGSMITH_TRACER_PROVIDER: globalTracerProvider, + DEFAULT_LANGSMITH_SPAN_PROCESSOR, + DEFAULT_LANGSMITH_SPAN_EXPORTER, + }; + setDefaultOTLPTracerComponents(defaultComponents); + return defaultComponents; + } +}; diff --git a/src/lib/experimental/otel/translator.ts b/src/lib/experimental/otel/translator.ts new file mode 100644 index 0000000..5a55745 --- /dev/null +++ b/src/lib/experimental/otel/translator.ts @@ -0,0 +1,622 @@ +import type { OTELContext, OTELSpan } from "./types.js"; +import type { KVMap, RunCreate, RunUpdate } from "../../schemas.js"; +import * as constants from "./constants.js"; +import { getOTELTrace } from "../../singletons/otel.js"; + +const WELL_KNOWN_OPERATION_NAMES: Record = { + llm: "chat", + tool: "execute_tool", + retriever: "embeddings", + embedding: "embeddings", + prompt: "chat", +}; + +function getOperationName(runType: string): string { + return WELL_KNOWN_OPERATION_NAMES[runType] || runType; +} + +function isPrimitive(value: unknown): value is string | number | boolean { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +export type SerializedRunOperation< + T extends "post" | "patch" = "post" | "patch", +> = { + operation: T; + id: string; + trace_id: string; + run: T extends "post" ? RunCreate : RunUpdate; +}; + +export class LangSmithToOTELTranslator { + private spans: Map = new Map(); + + exportBatch( + operations: SerializedRunOperation[], + otelContextMap: Map, + ): void { + for (const op of operations) { + try { + if (!op.run) { + continue; + } + + if (op.operation === "post") { + const span = this.createSpanForRun( + op, + op.run as RunCreate, + otelContextMap.get(op.id), + ); + if (span && !op.run.end_time) { + this.spans.set(op.id, span); + } + } else { + this.updateSpanForRun(op, op.run); + } + } catch (e) { + console.error(`Error processing operation ${op.id}:`, e); + } + } + } + + private createSpanForRun( + op: SerializedRunOperation, + runInfo: RunCreate, + otelContext?: OTELContext, + ): OTELSpan | undefined { + const activeSpan = otelContext && getOTELTrace().getSpan(otelContext); + if (!activeSpan) { + return; + } + try { + return this.finishSpanSetup(activeSpan, runInfo, op); + } catch (e) { + console.error(`Failed to create span for run ${op.id}:`, e); + return undefined; + } + } + + private finishSpanSetup( + span: OTELSpan, + runInfo: RunCreate | RunUpdate, + op: SerializedRunOperation, + ): OTELSpan { + // Set all attributes + this.setSpanAttributes(span, runInfo, op); + + // Set status based on error + if (runInfo.error) { + span.setStatus({ code: 2 }); // ERROR status + span.recordException(new Error(runInfo.error)); + } else { + span.setStatus({ code: 1 }); // OK status + } + + // End the span if end_time is present + if (runInfo.end_time) { + span.end(new Date(runInfo.end_time)); + } + + return span; + } + + private updateSpanForRun( + op: SerializedRunOperation, + runInfo: RunUpdate, + ): void { + try { + const span = this.spans.get(op.id); + if (!span) { + console.debug(`No span found for run ${op.id} during update`); + return; + } + + // Update attributes + this.setSpanAttributes(span, runInfo, op); + + // Update status based on error + if (runInfo.error) { + span.setStatus({ code: 2 }); // ERROR status + span.recordException(new Error(runInfo.error)); + } else { + span.setStatus({ code: 1 }); // OK status + } + + // End the span if end_time is present + const endTime = runInfo.end_time; + if (endTime) { + span.end(new Date(endTime)); + this.spans.delete(op.id); + } + } catch (e) { + console.error(`Failed to update span for run ${op.id}:`, e); + } + } + + private extractModelName(runInfo: RunCreate | RunUpdate) { + // Try to get model name from metadata + if (runInfo.extra?.metadata) { + const metadata = runInfo.extra.metadata; + + // First check for ls_model_name in metadata + if (metadata.ls_model_name) { + return metadata.ls_model_name; + } + + // Then check invocation_params for model info + if (metadata.invocation_params) { + const invocationParams = metadata.invocation_params; + if (invocationParams.model) { + return invocationParams.model; + } else if (invocationParams.model_name) { + return invocationParams.model_name; + } + } + } + + return; + } + + private setSpanAttributes( + span: OTELSpan, + runInfo: RunCreate | RunUpdate, + op: SerializedRunOperation, + ): void { + if ("run_type" in runInfo && runInfo.run_type) { + span.setAttribute(constants.LANGSMITH_RUN_TYPE, runInfo.run_type); + // Set GenAI attributes according to OTEL semantic conventions + const operationName = getOperationName(runInfo.run_type || "chain"); + span.setAttribute(constants.GEN_AI_OPERATION_NAME, operationName); + } + + if ("name" in runInfo && runInfo.name) { + span.setAttribute(constants.LANGSMITH_NAME, runInfo.name); + } + + if ("session_id" in runInfo && runInfo.session_id) { + span.setAttribute(constants.LANGSMITH_SESSION_ID, runInfo.session_id); + } + + if ("session_name" in runInfo && runInfo.session_name) { + span.setAttribute(constants.LANGSMITH_SESSION_NAME, runInfo.session_name); + } + + // Set gen_ai.system + this.setGenAiSystem(span, runInfo); + + // Set model name if available + const modelName = this.extractModelName(runInfo); + if (modelName) { + span.setAttribute(constants.GEN_AI_REQUEST_MODEL, modelName); + } + + // Set usage from metadata if available + // This can be overriden by `run.outputs.usage_metadata` later if present. + if ( + runInfo.extra?.metadata?.usage_metadata && + typeof runInfo.extra.metadata.usage_metadata === "object" + ) { + span.setAttribute( + constants.LANGSMITH_USAGE_METADATA, + JSON.stringify(runInfo.extra.metadata.usage_metadata), + ); + } + + // Set token usage information + if ( + "prompt_tokens" in runInfo && + typeof runInfo.prompt_tokens === "number" + ) { + span.setAttribute( + constants.GEN_AI_USAGE_INPUT_TOKENS, + runInfo.prompt_tokens, + ); + } + + if ( + "completion_tokens" in runInfo && + typeof runInfo.completion_tokens === "number" + ) { + span.setAttribute( + constants.GEN_AI_USAGE_OUTPUT_TOKENS, + runInfo.completion_tokens, + ); + } + + if ("total_tokens" in runInfo && typeof runInfo.total_tokens === "number") { + span.setAttribute( + constants.GEN_AI_USAGE_TOTAL_TOKENS, + runInfo.total_tokens, + ); + } + + // Set other parameters from invocation_params + this.setInvocationParameters(span, runInfo); + + if ("run_type" in runInfo && runInfo.run_type === "tool") { + if ("name" in runInfo && runInfo.name) { + span.setAttribute(constants.GEN_AI_TOOL_NAME, runInfo.name); + } + // Match Python: prefer extra.tool_call_id over metadata.tool_call_id. + const toolCallId = + runInfo.extra?.tool_call_id || runInfo.extra?.metadata?.tool_call_id; + if (toolCallId != null) { + span.setAttribute(constants.GEN_AI_TOOL_CALL_ID, String(toolCallId)); + } + } + + // Set metadata and tags if available + const metadata = runInfo.extra?.metadata || {}; + for (const [key, value] of Object.entries(metadata)) { + if (value !== null && value !== undefined) { + span.setAttribute( + `${constants.LANGSMITH_METADATA}.${key}`, + isPrimitive(value) ? String(value) : JSON.stringify(value), + ); + } + } + + const tags = runInfo.tags; + if (tags && Array.isArray(tags)) { + span.setAttribute(constants.LANGSMITH_TAGS, tags.join(", ")); + } else if (tags) { + span.setAttribute(constants.LANGSMITH_TAGS, String(tags)); + } + + // Support additional serialized attributes, if present + if ("serialized" in runInfo && typeof runInfo.serialized === "object") { + const serialized = runInfo.serialized as KVMap; + if (serialized.name) { + span.setAttribute( + constants.GEN_AI_SERIALIZED_NAME, + String(serialized.name), + ); + } + if (serialized.signature) { + span.setAttribute( + constants.GEN_AI_SERIALIZED_SIGNATURE, + String(serialized.signature), + ); + } + if (serialized.doc) { + span.setAttribute( + constants.GEN_AI_SERIALIZED_DOC, + String(serialized.doc), + ); + } + } + + // Set inputs/outputs if available + this.setIOAttributes(span, op); + } + + private setGenAiSystem(span: OTELSpan, runInfo: RunCreate | RunUpdate): void { + // Default to "langchain" if we can't determine the system + let system = "langchain"; + + // Extract model name to determine the system + const modelName = this.extractModelName(runInfo); + if (modelName) { + const modelLower = modelName.toLowerCase(); + if (modelLower.includes("anthropic") || modelLower.startsWith("claude")) { + system = "anthropic"; + } else if (modelLower.includes("bedrock")) { + system = "aws.bedrock"; + } else if ( + modelLower.includes("azure") && + modelLower.includes("openai") + ) { + system = "az.ai.openai"; + } else if ( + modelLower.includes("azure") && + modelLower.includes("inference") + ) { + system = "az.ai.inference"; + } else if (modelLower.includes("cohere")) { + system = "cohere"; + } else if (modelLower.includes("deepseek")) { + system = "deepseek"; + } else if (modelLower.includes("gemini")) { + system = "gemini"; + } else if (modelLower.includes("groq")) { + system = "groq"; + } else if (modelLower.includes("watson") || modelLower.includes("ibm")) { + system = "ibm.watsonx.ai"; + } else if (modelLower.includes("mistral")) { + system = "mistral_ai"; + } else if (modelLower.includes("gpt") || modelLower.includes("openai")) { + system = "openai"; + } else if ( + modelLower.includes("perplexity") || + modelLower.includes("sonar") + ) { + system = "perplexity"; + } else if (modelLower.includes("vertex")) { + system = "vertex_ai"; + } else if (modelLower.includes("xai") || modelLower.includes("grok")) { + system = "xai"; + } + } + + span.setAttribute(constants.GEN_AI_SYSTEM, system); + } + + private setInvocationParameters( + span: OTELSpan, + runInfo: RunCreate | RunUpdate, + ): void { + const tools = runInfo.extra?.invocation_params?.tools; + if (Array.isArray(tools) && tools.length > 0) { + span.setAttribute( + constants.GEN_AI_TOOL_DEFINITIONS, + JSON.stringify(tools), + ); + } + + if (!runInfo.extra?.metadata?.invocation_params) { + return; + } + + const invocationParams = runInfo.extra.metadata.invocation_params; + + // Set relevant invocation parameters + if (invocationParams.max_tokens !== undefined) { + span.setAttribute( + constants.GEN_AI_REQUEST_MAX_TOKENS, + invocationParams.max_tokens, + ); + } + + if (invocationParams.temperature !== undefined) { + span.setAttribute( + constants.GEN_AI_REQUEST_TEMPERATURE, + invocationParams.temperature, + ); + } + + if (invocationParams.top_p !== undefined) { + span.setAttribute(constants.GEN_AI_REQUEST_TOP_P, invocationParams.top_p); + } + + if (invocationParams.frequency_penalty !== undefined) { + span.setAttribute( + constants.GEN_AI_REQUEST_FREQUENCY_PENALTY, + invocationParams.frequency_penalty, + ); + } + + if (invocationParams.presence_penalty !== undefined) { + span.setAttribute( + constants.GEN_AI_REQUEST_PRESENCE_PENALTY, + invocationParams.presence_penalty, + ); + } + } + + private setIOAttributes(span: OTELSpan, op: SerializedRunOperation): void { + if (op.run.inputs) { + try { + const inputs = op.run.inputs; + + if (typeof inputs === "object" && inputs !== null) { + if (inputs.model && Array.isArray(inputs.messages)) { + span.setAttribute(constants.GEN_AI_REQUEST_MODEL, inputs.model); + } + + // Set additional request attributes if available + if (inputs.stream !== undefined) { + span.setAttribute( + constants.LANGSMITH_REQUEST_STREAMING, + inputs.stream, + ); + } + if (inputs.extra_headers) { + span.setAttribute( + constants.LANGSMITH_REQUEST_HEADERS, + JSON.stringify(inputs.extra_headers), + ); + } + if (inputs.extra_query) { + span.setAttribute( + constants.GEN_AI_REQUEST_EXTRA_QUERY, + JSON.stringify(inputs.extra_query), + ); + } + if (inputs.extra_body) { + span.setAttribute( + constants.GEN_AI_REQUEST_EXTRA_BODY, + JSON.stringify(inputs.extra_body), + ); + } + } + + span.setAttribute(constants.GENAI_PROMPT, JSON.stringify(inputs)); + } catch (e) { + console.debug(`Failed to process inputs for run ${op.id}`, e); + } + } + + if (op.run.outputs) { + try { + const outputs = op.run.outputs; + + // Extract token usage from outputs (for LLM runs) + const tokenUsage = this.getUnifiedRunTokens(outputs); + if (tokenUsage) { + span.setAttribute(constants.GEN_AI_USAGE_INPUT_TOKENS, tokenUsage[0]); + span.setAttribute( + constants.GEN_AI_USAGE_OUTPUT_TOKENS, + tokenUsage[1], + ); + span.setAttribute( + constants.GEN_AI_USAGE_TOTAL_TOKENS, + tokenUsage[0] + tokenUsage[1], + ); + } + + if (outputs && typeof outputs === "object") { + if (outputs.model) { + span.setAttribute( + constants.GEN_AI_RESPONSE_MODEL, + String(outputs.model), + ); + } + + // Extract additional response attributes + if (outputs.id) { + span.setAttribute(constants.GEN_AI_RESPONSE_ID, outputs.id); + } + + if (outputs.choices && Array.isArray(outputs.choices)) { + const finishReasons = outputs.choices + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((choice: any) => choice.finish_reason) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .filter((reason: any) => reason) + .map(String); + if (finishReasons.length > 0) { + span.setAttribute( + constants.GEN_AI_RESPONSE_FINISH_REASONS, + finishReasons.join(", "), + ); + } + } + + if (outputs.service_tier) { + span.setAttribute( + constants.GEN_AI_RESPONSE_SERVICE_TIER, + outputs.service_tier, + ); + } + + if (outputs.system_fingerprint) { + span.setAttribute( + constants.GEN_AI_RESPONSE_SYSTEM_FINGERPRINT, + outputs.system_fingerprint, + ); + } + + if ( + outputs.usage_metadata && + typeof outputs.usage_metadata === "object" + ) { + const usageMetadata = outputs.usage_metadata; + + // Set usage from outputs if available + // This overrides the usage from metadata if present. + span.setAttribute( + constants.LANGSMITH_USAGE_METADATA, + JSON.stringify(usageMetadata), + ); + + if (usageMetadata.input_token_details) { + span.setAttribute( + constants.GEN_AI_USAGE_INPUT_TOKEN_DETAILS, + JSON.stringify(usageMetadata.input_token_details), + ); + } + if (usageMetadata.output_token_details) { + span.setAttribute( + constants.GEN_AI_USAGE_OUTPUT_TOKEN_DETAILS, + JSON.stringify(usageMetadata.output_token_details), + ); + } + } + } + + span.setAttribute(constants.GENAI_COMPLETION, JSON.stringify(outputs)); + } catch (e) { + console.debug(`Failed to process outputs for run ${op.id}`, e); + } + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private getUnifiedRunTokens(outputs: any): [number, number] | null { + if (!outputs) { + return null; + } + + // Search in non-generations lists + let tokenUsage = this.extractUnifiedRunTokens(outputs.usage_metadata); + if (tokenUsage) { + return tokenUsage; + } + + // Find if direct kwarg in outputs + const keys = Object.keys(outputs); + for (const key of keys) { + const haystack = outputs[key]; + if (!haystack || typeof haystack !== "object") { + continue; + } + + tokenUsage = this.extractUnifiedRunTokens(haystack.usage_metadata); + if (tokenUsage) { + return tokenUsage; + } + + if ( + haystack.lc === 1 && + haystack.kwargs && + typeof haystack.kwargs === "object" + ) { + tokenUsage = this.extractUnifiedRunTokens( + haystack.kwargs.usage_metadata, + ); + if (tokenUsage) { + return tokenUsage; + } + } + } + + // Find in generations + const generations = outputs.generations || []; + if (!Array.isArray(generations)) { + return null; + } + + const flatGenerations = Array.isArray(generations[0]) + ? generations.flat() + : generations; + + for (const generation of flatGenerations) { + if ( + typeof generation === "object" && + generation.message && + typeof generation.message === "object" && + generation.message.kwargs && + typeof generation.message.kwargs === "object" + ) { + tokenUsage = this.extractUnifiedRunTokens( + generation.message.kwargs.usage_metadata, + ); + if (tokenUsage) { + return tokenUsage; + } + } + } + + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private extractUnifiedRunTokens(outputs: any): [number, number] | null { + if (!outputs || typeof outputs !== "object") { + return null; + } + + if ( + typeof outputs.input_tokens !== "number" || + typeof outputs.output_tokens !== "number" + ) { + return null; + } + + return [outputs.input_tokens, outputs.output_tokens]; + } +} diff --git a/src/lib/experimental/otel/types.ts b/src/lib/experimental/otel/types.ts new file mode 100644 index 0000000..3e13f67 --- /dev/null +++ b/src/lib/experimental/otel/types.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +// Local type definitions to avoid importing @opentelemetry/api +// These match the OpenTelemetry API interfaces but don't require the package to be installed + +export interface OTELSpan { + setAttribute: (key: string, value: any) => void; + setStatus: (status: { code: number; message?: string }) => void; + recordException: (exception: Error | string) => void; + end: (endTime?: number | Date) => void; +} + +export interface OTELTracer { + startSpan: (name: string, options?: any) => OTELSpan; + startActiveSpan: (name: string, options: any, fn: () => any) => any; +} + +export interface OTELContext { + // Context type - opaque object in OpenTelemetry +} + +export interface OTELTracerProvider { + getTracer: (name: string, version?: string) => OTELTracer; +} + +export interface OTELSpanContext { + traceId: string; + spanId: string; + isRemote?: boolean; + traceFlags: number; + traceState?: any; +} + +export interface OTELTraceFlags { + NONE: number; + SAMPLED: number; +} diff --git a/src/lib/experimental/otel/utils.ts b/src/lib/experimental/otel/utils.ts new file mode 100644 index 0000000..84ef0e1 --- /dev/null +++ b/src/lib/experimental/otel/utils.ts @@ -0,0 +1,29 @@ +/** + * Get UUID string from OpenTelemetry trace ID hex string. + * @param traceId - The hex string trace ID to convert + * @returns UUID string representation + */ +export function getUuidFromOtelTraceId(traceId: string): string { + // Insert hyphens to convert back to UUID format + return `${traceId.substring(0, 8)}-${traceId.substring( + 8, + 12, + )}-${traceId.substring(12, 16)}-${traceId.substring( + 16, + 20, + )}-${traceId.substring(20, 32)}`; +} + +/** + * Get UUID string from OpenTelemetry span ID hex string. + * @param spanId - The hex string span ID to convert (8 bytes/16 hex chars) + * @returns UUID string representation with zero padding at the front + */ +export function getUuidFromOtelSpanId(spanId: string): string { + // Pad with zeros at the front, then format as UUID + const paddedHex = spanId.padStart(16, "0"); + return `00000000-0000-0000-${paddedHex.substring(0, 4)}-${paddedHex.substring( + 4, + 16, + )}`; +} diff --git a/src/lib/experimental/vercel/_agent_type.ts b/src/lib/experimental/vercel/_agent_type.ts new file mode 100644 index 0000000..0a811b3 --- /dev/null +++ b/src/lib/experimental/vercel/_agent_type.ts @@ -0,0 +1,34 @@ +import { isRunTree } from "../../run_trees.js"; +import { getCurrentRunTree } from "../../singletons/traceable.js"; +import { + LsAgentType, + NON_ROOT_LS_AGENT_TYPES, + lsAgentTypeMetadata, + resolveDefaultLsAgentType, +} from "../../utils/ls_agent_type.js"; + +// Vercel builds its traceable's `metadata` config eagerly, before traceable +// itself runs — this resolver stamps `ls_agent_type` at that moment. +// Traceable's own propagation would carry a parent's middleware/subagent/ +// compaction/"root" tag down on its own. This resolver is needed +// for correct default tagging of the root `ls_agent_type` == root +// and tagging of subagents. + +export function resolveVercelLsAgentType( + parentRunTree?: unknown, +): LsAgentType | undefined { + const parent = parentRunTree ?? getCurrentRunTree(true); + if (!isRunTree(parent)) return resolveDefaultLsAgentType(parent); + + const parentTag = parent.extra?.metadata?.ls_agent_type; + if (typeof parentTag === "string" && NON_ROOT_LS_AGENT_TYPES.has(parentTag)) { + return parentTag as LsAgentType; + } + if (parent.run_type === "tool") return "subagent"; + if (parentTag === "root") return "root"; + return undefined; +} + +export function vercelLsAgentTypeMetadata(parentRunTree?: unknown) { + return lsAgentTypeMetadata(resolveVercelLsAgentType(parentRunTree)); +} diff --git a/src/lib/experimental/vercel/index.ts b/src/lib/experimental/vercel/index.ts new file mode 100644 index 0000000..adcf1d2 --- /dev/null +++ b/src/lib/experimental/vercel/index.ts @@ -0,0 +1,3 @@ +export * from "./wrap.js"; +export { LangSmithTelemetry } from "./telemetry.js"; +export type { LangSmithTelemetryConfig } from "./telemetry.js"; diff --git a/src/lib/experimental/vercel/middleware.ts b/src/lib/experimental/vercel/middleware.ts new file mode 100644 index 0000000..081b47a --- /dev/null +++ b/src/lib/experimental/vercel/middleware.ts @@ -0,0 +1,316 @@ +import type { + LanguageModelV2Middleware, + LanguageModelV2StreamPart, + LanguageModelV2CallOptions, + LanguageModelV2Message, + SharedV2ProviderMetadata, + LanguageModelV2FinishReason, +} from "@ai-sdk/provider"; +import type { RunTree, RunTreeConfig } from "../../run_trees.js"; +import { getCurrentRunTree, traceable } from "../../traceable.js"; +import { + convertMessageToTracedFormat, + setUsageMetadataOnRunTree, +} from "./utils.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const _formatTracedInputs = (params: LanguageModelV2CallOptions) => { + const { prompt, ...rest } = params; + if (prompt == null) { + return params; + } + if (Array.isArray(prompt)) { + return { + ...rest, + messages: prompt.map((message) => convertMessageToTracedFormat(message)), + }; + } + return rest; +}; + +const _formatTracedOutputs = ( + outputs: Record, + includeHttpDetails = false, +) => { + let formattedOutputs: Record; + + if (includeHttpDetails) { + // Include all fields including raw request/response/usage + formattedOutputs = { ...outputs }; + } else { + // Extract only the fields we want to trace, excluding raw request/response/usage + const { request: _, response: __, ...messageFields } = outputs; + formattedOutputs = { ...messageFields }; + } + + if (formattedOutputs.role == null) { + formattedOutputs.role = formattedOutputs.type ?? "assistant"; + } + return convertMessageToTracedFormat( + formattedOutputs as LanguageModelV2Message, + ); +}; + +type StandardTextBlock = { type: "text"; text: string }; +type StandardReasoningBlock = { + type: "reasoning"; + reasoning: string; + extras?: Record; +}; + +export type AggregatedDoStreamOutput = { + content: (StandardReasoningBlock | StandardTextBlock)[]; + role: "assistant"; + tool_calls: { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; + }[]; + providerMetadata?: SharedV2ProviderMetadata; + finishReason?: LanguageModelV2FinishReason; +}; + +/** + * AI SDK middleware that wraps an AI SDK 6 or 5 model and adds LangSmith tracing. + */ +export function LangSmithMiddleware(config?: { + name: string; + modelId?: string; + lsConfig?: Partial> & { + processInputs?: ( + inputs: Record, + ) => Record; + processOutputs?: ( + outputs: Record, + ) => Record | Promise>; + traceRawHttp?: boolean; + }; +}): LanguageModelV2Middleware { + const { name, modelId, lsConfig } = config ?? {}; + + return { + wrapGenerate: async ({ doGenerate, params }) => { + const traceableFunc = traceable( + async (_params: typeof params) => { + const result = await doGenerate(); + const currentRunTree = getCurrentRunTree(true); + if (currentRunTree !== undefined) { + setUsageMetadataOnRunTree(result, currentRunTree); + } + return result; + }, + { + ...lsConfig, + name: name ?? "ai.doGenerate", + run_type: "llm", + metadata: { + ls_integration: "vercel-ai-sdk", + ls_model_name: modelId, + ai_sdk_method: "ai.doGenerate", + ...lsConfig?.metadata, + }, + processInputs: (inputs) => { + const typedInputs = inputs as LanguageModelV2CallOptions; + const inputFormatter = + lsConfig?.processInputs ?? _formatTracedInputs; + return inputFormatter(typedInputs); + }, + processOutputs: (outputs) => { + const typedOutputs = outputs as Awaited< + ReturnType + >; + if (lsConfig?.processOutputs) { + return lsConfig.processOutputs(typedOutputs); + } + return _formatTracedOutputs(typedOutputs, lsConfig?.traceRawHttp); + }, + }, + ); + const res = await traceableFunc(params); + return res; + }, + wrapStream: async ({ doStream, params }) => { + const parentRunTree = getCurrentRunTree(true); + let runTree: RunTree | undefined; + if ( + parentRunTree != null && + typeof parentRunTree === "object" && + typeof parentRunTree.createChild === "function" + ) { + const inputFormatter = lsConfig?.processInputs ?? _formatTracedInputs; + const formattedInputs = inputFormatter(params); + runTree = parentRunTree?.createChild({ + ...lsConfig, + name: name ?? "ai.doStream", + run_type: "llm", + metadata: { + ls_integration: "vercel-ai-sdk", + ls_model_name: modelId, + ai_sdk_method: "ai.doStream", + ...lsConfig?.metadata, + }, + inputs: formattedInputs, + }); + } + + await runTree?.postRun(); + try { + const { stream, ...rest } = await doStream(); + const chunks: LanguageModelV2StreamPart[] = []; + const transformStream = new TransformStream({ + async transform(chunk: LanguageModelV2StreamPart, controller) { + if ( + chunk.type === "tool-input-start" || + chunk.type === "text-start" + ) { + // Only necessary to log the first token event + if ( + runTree?.events == null || + (Array.isArray(runTree.events) && runTree.events.length === 0) + ) { + runTree?.addEvent({ name: "new_token" }); + } + } else if (chunk.type === "finish") { + runTree?.addEvent({ name: "end" }); + } + chunks.push(chunk); + controller.enqueue(chunk); + }, + + async flush() { + try { + const output = chunks.reduce( + (aggregated: AggregatedDoStreamOutput, chunk) => { + if (chunk.type === "text-delta") { + if (aggregated.content.at(-1)?.type !== "text") { + aggregated.content.push({ type: "text", text: "" }); + } + const contentBlock = aggregated.content.at( + -1, + ) as NonNullable; + if (chunk.delta != null) { + contentBlock.text += chunk.delta; + } else if ( + "textDelta" in chunk && + chunk.textDelta != null + ) { + // AI SDK 4 shim + contentBlock.text += chunk.textDelta; + } + return aggregated; + } else if (chunk.type === "reasoning-delta") { + if (aggregated.content.at(-1)?.type !== "reasoning") { + aggregated.content.push({ + type: "reasoning", + reasoning: "", + extras: chunk.providerMetadata, + }); + } + const reasoningBlock = aggregated.content.at( + -1, + ) as NonNullable; + if (chunk.delta != null) { + reasoningBlock.reasoning += chunk.delta; + } + return aggregated; + } else if (chunk.type === "tool-call") { + const matchingToolCall = aggregated.tool_calls.find( + (call) => call.id === chunk.toolCallId, + ); + if (matchingToolCall != null) { + return aggregated; + } + let chunkArgs = chunk.input; + if ( + chunkArgs == null && + "args" in chunk && + typeof chunk.args === "string" + ) { + chunkArgs = chunk.args; + } + return { + ...aggregated, + tool_calls: [ + ...aggregated.tool_calls, + { + id: chunk.toolCallId, + type: "function" as const, + function: { + name: chunk.toolName, + arguments: chunkArgs, + }, + }, + ], + }; + } else if (chunk.type === "finish") { + if (runTree != null) { + setUsageMetadataOnRunTree(chunk, runTree); + } + return { + ...aggregated, + providerMetadata: chunk.providerMetadata, + finishReason: chunk.finishReason, + }; + } else { + return aggregated; + } + }, + { + content: [], + role: "assistant", + tool_calls: [], + }, + ); + // Add raw request/response for tracing only (not part of aggregated output) + const outputForTracing: Record = { + ...output, + request: rest.request, + response: rest.response, + }; + if ( + "content" in outputForTracing && + Array.isArray(outputForTracing.content) && + outputForTracing.content.length === 1 && + outputForTracing.content[0].type === "text" + ) { + outputForTracing.content = outputForTracing.content[0].text; + } + let formattedOutputs: Record; + if (lsConfig?.processOutputs) { + formattedOutputs = + await lsConfig.processOutputs(outputForTracing); + } else { + formattedOutputs = _formatTracedOutputs( + outputForTracing, + lsConfig?.traceRawHttp, + ); + } + await runTree?.end(formattedOutputs); + } catch (error: any) { + await runTree?.end(undefined, error.message ?? String(error)); + throw error; + } finally { + await runTree?.patchRun({ + excludeInputs: true, + }); + } + }, + }); + + return { + stream: stream.pipeThrough(transformStream), + ...rest, + }; + } catch (error: any) { + await runTree?.end(undefined, error.message ?? String(error)); + await runTree?.patchRun({ + excludeInputs: true, + }); + throw error; + } + }, + }; +} diff --git a/src/lib/experimental/vercel/telemetry.ts b/src/lib/experimental/vercel/telemetry.ts new file mode 100644 index 0000000..f869525 --- /dev/null +++ b/src/lib/experimental/vercel/telemetry.ts @@ -0,0 +1,848 @@ +import type { ModelMessage, StepResult, Telemetry, TypedToolCall } from "ai"; +import { isRunTree, RunTree, RunTreeConfig } from "../../run_trees.js"; +import { allowUnprocessedPayloads } from "../../env.js"; +import { + _PROCESSING_FAILED_KEY, + _processingFailed, +} from "../../singletons/constants.js"; +import { getCurrentRunTree, withRunTree } from "../../traceable.js"; +import { isEnvTracingEnabled } from "../../env.js"; +import { convertMessageToTracedFormat } from "./utils.js"; +import { setUsageMetadataOnRunTree } from "./utils.js"; +import type { KVMap } from "../../schemas.js"; +import { isPrimitive, isRecord } from "../../utils/types.js"; +import { vercelLsAgentTypeMetadata } from "./_agent_type.js"; + +/** + * Configuration options for creating a LangSmith telemetry integration + * for the Vercel AI SDK. + */ +export interface LangSmithTelemetryConfig { + /** + * Custom name for the root span. If not provided, defaults to the + * model display name or "generateText"/"streamText". + */ + name?: string; + + /** + * Run type for the root span. Defaults to "chain". + */ + runType?: string; + + /** + * Additional metadata to attach to all spans. + */ + metadata?: KVMap; + + /** + * Tags to attach to all spans. + */ + tags?: string[]; + + /** + * Custom LangSmith client configuration. + */ + client?: RunTreeConfig["client"]; + + /** + * Project name to log runs to. + */ + projectName?: string; + + /** + * Transform inputs before logging on the root span. + */ + processInputs?: (inputs: KVMap) => KVMap; + + /** + * Transform outputs before logging on the root span. + */ + processOutputs?: (outputs: KVMap) => KVMap; + + /** + * Transform inputs before logging on child LLM step spans. + */ + processChildLLMRunInputs?: (inputs: KVMap) => KVMap; + + /** + * Transform outputs before logging on child LLM step spans. + */ + processChildLLMRunOutputs?: (outputs: KVMap) => KVMap; + + /** + * Whether to include intermediate step details in the output. + */ + traceResponseMetadata?: boolean; + + /** + * Whether to include raw HTTP request/response bodies. + */ + traceRawHttp?: boolean; + + /** + * Additional RunTree configuration to pass through. + */ + extra?: KVMap; + + /** + * Whether to enable tracing. + * @default true if LANGSMITH_TRACING is set to "true" + */ + tracingEnabled?: boolean; +} + +function _formatMessages(messages: ModelMessage[]): Record[] { + if (!Array.isArray(messages)) return messages; + return messages.map((msg) => convertMessageToTracedFormat(msg)); +} + +// oxlint-disable-next-line typescript/no-explicit-any +function _formatToolCalls(toolCalls: TypedToolCall[]) { + return toolCalls.map((tc) => ({ + id: tc.toolCallId, + type: "function", + function: { + name: tc.toolName, + arguments: isPrimitive(tc.input) + ? String(tc.input) + : JSON.stringify(tc.input), + }, + })); +} + +function _formatStepOutput( + // oxlint-disable-next-line typescript/no-explicit-any + event: StepResult, + traceRawHttp?: boolean, +): KVMap { + // Build an assistant-style message from the step result + const output: Record = { role: "assistant" }; + + // Text content + if (event.content != null) { + output.content = event.content; + } else if (event.text != null) { + output.content = event.text; + } + + // Tool calls + if (Array.isArray(event.toolCalls) && event.toolCalls.length > 0) { + output.tool_calls = _formatToolCalls(event.toolCalls); + } + + if (event.finishReason != null) { + output.finish_reason = event.finishReason; + } + + if (traceRawHttp) { + if (event.request != null) output.request = event.request; + if (event.response != null) output.response = event.response; + } + + return convertMessageToTracedFormat(output) as KVMap; +} + +// Fail closed: a redactor that threw has redacted nothing, so the payload it +// was handed must not be traced. LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS opts out. +function droppedPayload( + payload: "inputs" | "outputs", + hook: string, + raw: KVMap, + e: unknown, +): KVMap { + if (allowUnprocessedPayloads()) { + console.error( + `Error in ${hook}. Tracing unprocessed ${payload} because ` + + "LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS is set:", + e, + ); + return raw; + } + console.error(`Error in ${hook}. Dropping ${payload}:`, e); + return { [_PROCESSING_FAILED_KEY]: _processingFailed(payload, hook) }; +} + +function _hasNonzeroUsageMetadata(runTree: RunTree): boolean { + const usageMetadata = runTree.extra?.metadata?.usage_metadata; + if (!isRecord(usageMetadata)) return false; + + return ["input_tokens", "output_tokens", "total_tokens"].some( + (key) => typeof usageMetadata[key] === "number" && usageMetadata[key] > 0, + ); +} + +/** + * Creates a LangSmith `Telemetry` for the Vercel AI SDK. + * + * This adapter implements the Vercel AI SDK's `Telemetry` interface + * and maps lifecycle events to LangSmith traces. It creates a root span for + * the entire generation, child LLM spans for each step, and tool spans for + * tool calls. + * + * ```ts + * import { generateText, registerTelemetry } from "ai"; + * import { LangSmithTelemetry } from "langsmith/experimental/vercel"; + * + * registerTelemetry(LangSmithTelemetry()); + * + * const result = await generateText({ + * model: openai("gpt-4o"), + * prompt: "Hello!", + * }); + * ``` + * + * @experimental Only available in Vercel AI SDK 7. + */ +export function LangSmithTelemetry( + config?: LangSmithTelemetryConfig, + // Ignore until Telemetry is released + // oxlint-disable-next-line typescript/no-explicit-any +): any { + const { + name: customName, + runType = "chain", + metadata: customMetadata, + tags: customTags, + client, + projectName, + processInputs, + processOutputs, + processChildLLMRunInputs, + processChildLLMRunOutputs, + traceResponseMetadata, + traceRawHttp, + tracingEnabled, + extra: customExtra, + } = config ?? {}; + + interface ExperimentalHarnessState { + lastStepContent: unknown[]; + completedStepMessages: Record[]; + toolResults: Map; + /** + * Temporary workaround until Harness Pi exposes usage for each inferred + * model step instead of reporting only aggregate usage at turn end. + */ + piAggregateUsageWorkaround?: { + latestStepRunTree?: RunTree; + deferredStepRunTree?: RunTree; + }; + } + + // Per-invocation state. Each generateText/streamText call gets its own + // context so the same integration object can be reused across calls. + interface InvocationState { + rootRunTree: RunTree; + stepRunTrees: Map; + toolRunTrees: Map; + experimental_harness?: ExperimentalHarnessState; + } + + function formatToolResultMessage( + toolCallId: string, + toolName: string, + toolOutput: unknown, + ): Record { + const rawOutput = isRecord(toolOutput) + ? toolOutput.type === "tool-result" + ? toolOutput.output + : "error" in toolOutput + ? toolOutput.error + : toolOutput + : toolOutput; + const artifact = rawOutput instanceof Error ? rawOutput.message : rawOutput; + const content = + typeof artifact === "string" + ? artifact + : JSON.stringify(artifact) ?? String(artifact); + return { + role: "tool", + content, + tool_call_id: toolCallId, + name: toolName, + artifact, + }; + } + + function appendToolResultMessage( + harnessState: ExperimentalHarnessState, + toolCallId: string, + toolName: string, + toolOutput: unknown, + ): Record { + const message = formatToolResultMessage(toolCallId, toolName, toolOutput); + harnessState.completedStepMessages.push(message); + return message; + } + + function getOpenStepOrRoot(state: InvocationState): RunTree { + let openStep: RunTree | undefined; + state.stepRunTrees.forEach((stepRt) => { + if (stepRt.end_time == null) { + openStep = stepRt; + } + }); + return openStep ?? state.rootRunTree; + } + + async function flushPiDeferredStepRun(state: InvocationState) { + const workaround = state.experimental_harness?.piAggregateUsageWorkaround; + if (workaround == null) return; + const stepRunTree = workaround.deferredStepRunTree; + if (stepRunTree == null) return; + workaround.deferredStepRunTree = undefined; + await stepRunTree.patchRun({ excludeInputs: true }); + } + + async function finalizeOpenToolRuns( + state: InvocationState, + opts?: { note?: string; error?: string }, + ) { + const entries = Array.from(state.toolRunTrees.entries()); + for (let i = 0; i < entries.length; i++) { + const [, toolRt] = entries[i]; + if (toolRt.end_time == null) { + if (opts?.error != null) { + await toolRt.end(undefined, opts.error); + } else { + await toolRt.end( + opts?.note != null ? { note: opts.note } : undefined, + ); + } + await toolRt.patchRun({ excludeInputs: true }); + } + } + state.toolRunTrees.clear(); + } + + /** Per-generation state keyed by AI SDK `callId` (stable across nested calls). */ + const invocationsByCallId = new Map(); + + const onStart: Telemetry["onStart"] = async (event) => { + if (!isEnvTracingEnabled(tracingEnabled)) return; + if (!("callId" in event) || typeof event.callId !== "string") return; + + // If called within an existing traceable context, nest under it + const parentRunTree = getCurrentRunTree(true); + + let inputs: KVMap = {}; + if (event.recordInputs !== false) { + if ("messages" in event && event.messages != null) { + inputs.messages = _formatMessages(event.messages); + } + + if ("prompt" in event && event.prompt != null) { + inputs.prompt = event.prompt; + } + + if ("instructions" in event && event.instructions != null) { + inputs.instructions = event.instructions; + } + + if ("system" in event && event.system != null) { + inputs.system = event.system; + } + + if ("tools" in event && event.tools != null) { + inputs.tools = Object.keys(event.tools); + } + + if ("runtimeContext" in event && event.runtimeContext != null) { + inputs.runtimeContext = event.runtimeContext; + } + + if ("toolsContext" in event && event.toolsContext != null) { + inputs.toolsContext = event.toolsContext; + } + + // Apply user-provided input processing + if (processInputs) { + try { + inputs = processInputs(inputs); + } catch (e) { + inputs = droppedPayload("inputs", "processInputs", inputs, e); + } + } + } + + const runTreeConfig: RunTreeConfig = { + name: customName ?? event.functionId ?? event.provider, + run_type: runType, + inputs, + tracingEnabled: true, + extra: { + ...customExtra, + metadata: { + ...customMetadata, + ai_sdk_method: event.operationId, + ...vercelLsAgentTypeMetadata(parentRunTree), + ls_model_name: event.modelId, + ls_integration: "vercel-ai-sdk-telemetry", + }, + }, + tags: customTags, + ...(client ? { client } : {}), + ...(projectName ? { project_name: projectName } : {}), + }; + + let rootRunTree: RunTree; + if (isRunTree(parentRunTree)) { + rootRunTree = parentRunTree.createChild(runTreeConfig); + } else { + rootRunTree = new RunTree(runTreeConfig); + } + await rootRunTree.postRun(); + invocationsByCallId.set(event.callId, { + rootRunTree, + stepRunTrees: new Map(), + toolRunTrees: new Map(), + experimental_harness: + event.operationId === "ai.harness" + ? { + lastStepContent: [], + completedStepMessages: [], + toolResults: new Map(), + piAggregateUsageWorkaround: + event.provider === "pi" ? {} : undefined, + } + : undefined, + }); + }; + + const onStepStart: Telemetry["onStepStart"] = async (event) => { + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + await flushPiDeferredStepRun(state); + const stepNumber: number = event.stepNumber ?? 0; + + let inputs: KVMap = {}; + if (event.recordInputs !== false) { + if ("messages" in event && event.messages != null) { + inputs.messages = [ + ..._formatMessages(event.messages), + ...(state.experimental_harness?.completedStepMessages ?? []), + ]; + } + + if ("runtimeContext" in event && event.runtimeContext != null) { + inputs.runtimeContext = event.runtimeContext; + } + + if ("toolsContext" in event && event.toolsContext != null) { + inputs.toolsContext = event.toolsContext; + } + + if (processChildLLMRunInputs) { + try { + inputs = processChildLLMRunInputs(inputs); + } catch (e) { + inputs = droppedPayload( + "inputs", + "processChildLLMRunInputs", + inputs, + e, + ); + } + } + } + + const stepRunTree = state.rootRunTree.createChild({ + name: event.provider, + run_type: "llm", + inputs, + extra: { + metadata: { + step_number: stepNumber, + ls_model_name: event.modelId, + }, + }, + }); + + state.stepRunTrees.set(stepNumber, stepRunTree); + const piUsageWorkaround = + state.experimental_harness?.piAggregateUsageWorkaround; + if (piUsageWorkaround != null) { + piUsageWorkaround.latestStepRunTree = stepRunTree; + } + await stepRunTree.postRun(); + }; + + const onLanguageModelCallStart: Telemetry["onLanguageModelCallStart"] = + async (event) => { + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + const stepRunTree = getOpenStepOrRoot(state); + if (stepRunTree.run_type !== "llm") return; + + const prevParams = isRecord(stepRunTree.extra?.invocation_params) + ? stepRunTree.extra.invocation_params + : {}; + + type AsPlain = { -readonly [K in keyof T]?: T[K] }; + const nextParams: AsPlain = { ...event }; + + // Remove properties that are already in the step run tree + delete nextParams.messages; + delete nextParams.provider; + delete nextParams.modelId; + + // Remove telemetry options (except functionId) + delete nextParams.recordInputs; + delete nextParams.recordOutputs; + delete nextParams.includeToolsContext; + + // Massage tools for LangSmith to render schema nicely + nextParams.tools = nextParams.tools?.map((tool) => { + const newTool = { ...tool }; + if ("inputSchema" in newTool) { + newTool.input_schema = newTool.inputSchema; + delete newTool.inputSchema; + } + return newTool; + }); + + stepRunTree.extra = { + ...stepRunTree.extra, + invocation_params: { ...prevParams, ...nextParams }, + }; + }; + + const onToolExecutionStart: Telemetry["onToolExecutionStart"] = async ( + event, + ) => { + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + const parentRunTree = state.rootRunTree; + + let inputs: KVMap = {}; + if (event.recordInputs !== false) { + let toolCallInput = event.toolCall.input; + if (typeof toolCallInput === "string") { + try { + toolCallInput = JSON.parse(toolCallInput); + } catch { + // The tool argument itself may be a plain string rather than JSON. + } + } + if (isRecord(toolCallInput)) { + inputs = { ...toolCallInput }; + } else if (toolCallInput !== undefined) { + inputs = { input: toolCallInput }; + } + } + + const toolRunTree = parentRunTree.createChild({ + name: event.toolCall.toolName, + run_type: "tool", + inputs, + extra: { + metadata: { + tool_call_id: event.toolCall.toolCallId, + ai_sdk_call_id: event.callId, + }, + }, + }); + await toolRunTree.postRun(); + state.toolRunTrees.set(event.toolCall.toolCallId, toolRunTree); + }; + + const onToolExecutionEnd: Telemetry["onToolExecutionEnd"] = async (event) => { + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + const toolRunTree = state.toolRunTrees.get(event.toolCall.toolCallId); + if (!toolRunTree) return; + const harnessState = state.experimental_harness; + if (harnessState != null) { + harnessState.toolResults.set(event.toolCall.toolCallId, { + toolName: event.toolCall.toolName, + output: event.toolOutput, + }); + } + + let outputs: KVMap | undefined; + let error: string | undefined; + + if (event.recordOutputs !== false) { + if (event.toolOutput.type === "tool-result") { + outputs = { output: event.toolOutput.output }; + } else if (isRecord(event.toolOutput) && "error" in event.toolOutput) { + const err = event.toolOutput.error; + error = err instanceof Error ? err.message : String(err); + } + } else { + outputs = {}; + } + + await toolRunTree.end( + outputs, + error, + Math.floor(toolRunTree.start_time + event.toolExecutionMs), + ); + await toolRunTree.patchRun({ excludeInputs: true }); + state.toolRunTrees.delete(event.toolCall.toolCallId); + }; + + const onStepFinish: Telemetry["onStepFinish"] = async (event) => { + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + const stepNumber: number = event.stepNumber ?? 0; + const stepRunTree = state.stepRunTrees.get(stepNumber); + if (!stepRunTree) return; + const harnessState = state.experimental_harness; + + let outputs = {}; + if (event.recordOutputs !== false) { + if (harnessState != null && Array.isArray(event.content)) { + harnessState.lastStepContent = [...event.content]; + } + outputs = _formatStepOutput(event, traceRawHttp); + + if (processChildLLMRunOutputs) { + try { + outputs = processChildLLMRunOutputs(outputs); + } catch (e) { + outputs = droppedPayload( + "outputs", + "processChildLLMRunOutputs", + outputs, + e, + ); + } + } + } + + if (harnessState != null && Array.isArray(event.content)) { + harnessState.completedStepMessages.push( + convertMessageToTracedFormat({ + role: "assistant", + content: event.content, + }), + ); + + for (const part of event.content) { + if ( + !isRecord(part) || + part.type !== "tool-call" || + typeof part.toolCallId !== "string" + ) { + continue; + } + const toolResult = harnessState.toolResults.get(part.toolCallId); + if (toolResult) { + appendToolResultMessage( + harnessState, + part.toolCallId, + toolResult.toolName, + toolResult.output, + ); + harnessState.toolResults.delete(part.toolCallId); + } + } + } + + // Set usage metadata + // @ts-expect-error SharedV4ProviderMetadata is not assignable to SharedV2ProviderMetadata + setUsageMetadataOnRunTree(event, stepRunTree); + + await stepRunTree.end(outputs); + const piUsageWorkaround = harnessState?.piAggregateUsageWorkaround; + if (piUsageWorkaround != null && !_hasNonzeroUsageMetadata(stepRunTree)) { + // Pi reports zero usage for inferred step boundaries and sends the real + // aggregate at turn end. Defer the final PATCH so that aggregate can be + // attached to the last LLM run without issuing a duplicate update. + piUsageWorkaround.deferredStepRunTree = stepRunTree; + } else { + await stepRunTree.patchRun({ excludeInputs: true }); + } + state.stepRunTrees.delete(stepNumber); + }; + + const onEnd: Telemetry["onEnd"] = async (event) => { + if (!("callId" in event) || typeof event.callId !== "string") return; + const state = invocationsByCallId.get(event.callId); + if (!state) return; + + const { rootRunTree } = state; + const harnessState = state.experimental_harness; + const piUsageWorkaround = harnessState?.piAggregateUsageWorkaround; + await finalizeOpenToolRuns(state, { note: "closed on finish" }); + + // Ensure any remaining step runs are closed + const remainingSteps = Array.from(state.stepRunTrees.entries()); + for (let i = 0; i < remainingSteps.length; i++) { + const [stepNumber, stepRt] = remainingSteps[i]; + if (stepRt.end_time == null) { + await stepRt.end({ note: "closed on finish" }); + if (piUsageWorkaround != null && !_hasNonzeroUsageMetadata(stepRt)) { + piUsageWorkaround.deferredStepRunTree = stepRt; + } else { + await stepRt.patchRun({ excludeInputs: true }); + } + } + state.stepRunTrees.delete(stepNumber); + } + + let outputs: KVMap = {}; + + if (event.recordOutputs !== false) { + // Final result output + if ("text" in event && event.text != null) { + outputs.content = event.text; + } else if ( + "content" in event && + event.content != null && + (!Array.isArray(event.content) || event.content.length > 0) + ) { + outputs.content = event.content; + } else if ( + harnessState != null && + harnessState.lastStepContent.length > 0 + ) { + // HarnessAgent currently reports an empty root `content` array even + // though each completed step contains assistant output. Only the final + // step belongs on the root; prior steps have their own child runs. + outputs.content = harnessState.lastStepContent; + } + + if (outputs.content != null) { + outputs.role = "assistant"; + } + + if ("object" in event && event.object != null) { + outputs.object = event.object; + } + + if ( + "toolCalls" in event && + Array.isArray(event.toolCalls) && + event.toolCalls.length > 0 + ) { + outputs.tool_calls = _formatToolCalls(event.toolCalls); + } + + if ("finishReason" in event && event.finishReason != null) { + outputs.finish_reason = event.finishReason; + } + + if ( + traceResponseMetadata && + "steps" in event && + Array.isArray(event.steps) + ) { + outputs.steps = event.steps.map((step, idx) => ({ + step_number: idx, + ..._formatStepOutput(step, traceRawHttp), + })); + } + + if (processOutputs) { + try { + outputs = processOutputs(outputs); + } catch (e) { + outputs = droppedPayload("outputs", "processOutputs", outputs, e); + } + } + } + + // Pi reports zero usage on inferred step boundaries and provides the actual + // aggregate only when the turn ends. Keep that usage on an LLM run rather + // than incorrectly elevating it to the parent chain. The latest step is the + // only accurate place available until Harness Pi exposes per-step usage. + const latestStep = piUsageWorkaround?.latestStepRunTree; + if ( + latestStep?.run_type === "llm" && + !_hasNonzeroUsageMetadata(latestStep) + ) { + if ("totalUsage" in event && event.totalUsage != null) { + setUsageMetadataOnRunTree( + // @ts-expect-error SharedV4ProviderMetadata is not assignable to SharedV2ProviderMetadata + { usage: event.totalUsage, providerMetadata: event.providerMetadata }, + latestStep, + ); + } else if ("usage" in event && event.usage != null) { + // @ts-expect-error SharedV4ProviderMetadata is not assignable to SharedV2ProviderMetadata + setUsageMetadataOnRunTree(event, latestStep); + } + } + + await rootRunTree.end(outputs); + await Promise.all([ + flushPiDeferredStepRun(state), + rootRunTree.patchRun({ excludeInputs: true }), + ]); + + invocationsByCallId.delete(event.callId); + }; + + const onError: Telemetry["onError"] = async (payload) => { + const callId = + typeof payload === "object" && + payload !== null && + "callId" in payload && + typeof (payload as { callId: unknown }).callId === "string" + ? (payload as { callId: string }).callId + : undefined; + const error = + typeof payload === "object" && payload !== null && "error" in payload + ? (payload as { error: unknown }).error + : payload; + + if (callId === undefined) return; + const state = invocationsByCallId.get(callId); + if (!state) return; + + const { rootRunTree } = state; + + const errorMsg = error instanceof Error ? error.message : String(error); + await finalizeOpenToolRuns(state, { error: errorMsg }); + + // Close any open step runs with error + const errorSteps = Array.from(state.stepRunTrees.entries()); + for (let i = 0; i < errorSteps.length; i++) { + const [stepNumber, stepRt] = errorSteps[i]; + if (stepRt.end_time == null) { + await stepRt.end(undefined, errorMsg); + await stepRt.patchRun({ excludeInputs: true }); + } + state.stepRunTrees.delete(stepNumber); + } + await flushPiDeferredStepRun(state); + + await rootRunTree.end(undefined, errorMsg); + await rootRunTree.patchRun({ excludeInputs: true }); + + invocationsByCallId.delete(callId); + }; + + const executeTool: Telemetry["executeTool"] = async (params: { + callId: string; + toolCallId: string; + execute: () => PromiseLike; + }): Promise => { + const state = invocationsByCallId.get(params.callId); + + const toolRunTree = state?.toolRunTrees.get(params.toolCallId); + if (toolRunTree != null) { + return withRunTree(toolRunTree, () => params.execute()) as Promise; + } + + return params.execute() as Promise; + }; + + return { + onStart, + onStepStart, + onLanguageModelCallStart, + onToolExecutionStart, + onToolExecutionEnd, + onStepFinish, + onEnd, + onError, + executeTool, + } satisfies Telemetry; +} diff --git a/src/lib/experimental/vercel/utils.ts b/src/lib/experimental/vercel/utils.ts new file mode 100644 index 0000000..b663ef4 --- /dev/null +++ b/src/lib/experimental/vercel/utils.ts @@ -0,0 +1,384 @@ +import type { + LanguageModelV2DataContent, + LanguageModelV2Usage, + SharedV2ProviderMetadata, +} from "@ai-sdk/provider"; +import type { ModelMessage, ToolCallPart } from "ai"; +import { isRecord } from "../../utils/types.js"; +import { RunTree } from "../../run_trees.js"; +import { + extractInputTokenDetails, + extractOutputTokenDetails, +} from "../../utils/vercel.js"; + +const guessMimetypeFromBase64 = (data: string) => { + // Check magic bytes from base64 data + const bytes = atob(data.substring(0, 20)); // Decode first few bytes + + // PNG: 89 50 4E 47 + if ( + bytes.charCodeAt(0) === 0x89 && + bytes.charCodeAt(1) === 0x50 && + bytes.charCodeAt(2) === 0x4e && + bytes.charCodeAt(3) === 0x47 + ) { + return "image/png"; + } + + // JPEG: FF D8 FF + if ( + bytes.charCodeAt(0) === 0xff && + bytes.charCodeAt(1) === 0xd8 && + bytes.charCodeAt(2) === 0xff + ) { + return "image/jpeg"; + } + + // GIF: 47 49 46 38 + if ( + bytes.charCodeAt(0) === 0x47 && + bytes.charCodeAt(1) === 0x49 && + bytes.charCodeAt(2) === 0x46 && + bytes.charCodeAt(3) === 0x38 + ) { + return "image/gif"; + } + + // WebP: 52 49 46 46 (RIFF) ... 57 45 42 50 (WEBP) + if ( + bytes.charCodeAt(0) === 0x52 && + bytes.charCodeAt(1) === 0x49 && + bytes.charCodeAt(2) === 0x46 && + bytes.charCodeAt(3) === 0x46 + ) { + if (bytes.indexOf("WEBP") !== -1) { + return "image/webp"; + } + } + + // PDF: 25 50 44 46 (%PDF) + if ( + bytes.charCodeAt(0) === 0x25 && + bytes.charCodeAt(1) === 0x50 && + bytes.charCodeAt(2) === 0x44 && + bytes.charCodeAt(3) === 0x46 + ) { + return "application/pdf"; + } + + return undefined; +}; + +// Extracted from AI SDK's FileData type +type AISDKDataContent = string | Uint8Array | ArrayBuffer | Buffer; +type AISDKProviderReference = { [provider: string]: string } & { type?: never }; + +// TODO: add support for AI SDK FileData +type AISDKFileData = + | { type: "data"; data: AISDKDataContent } + | { type: "url"; url: URL } + | { type: "reference"; reference: AISDKProviderReference } + | { type: "text"; text: string }; + +function _isAISDKFileData(input: unknown): input is AISDKFileData { + if (!isRecord(input)) return false; + if (input.type === "data" && "data" in input) return true; + if (input.type === "url" && "url" in input) return true; + if (input.type === "reference" && "reference" in input) return true; + if (input.type === "text" && "text" in input) return true; + return false; +} + +function _toUint8Array(fileData: unknown): Uint8Array | undefined { + // Covers `fileData: ArrayBuffer | Buffer | Uint8Array` + if (fileData instanceof Uint8Array) { + return fileData; + } + + if ( + fileData != null && + typeof fileData === "object" && + "type" in fileData && + "data" in fileData && + typeof fileData.data === "object" && + fileData.data instanceof Uint8Array + ) { + return fileData.data; + } + + if (fileData instanceof ArrayBuffer) { + return new Uint8Array(fileData); + } + + return undefined; +} + +export const normalizeFileDataAsDataURL = ( + fileData: + | AISDKFileData + | AISDKDataContent + | LanguageModelV2DataContent + | AISDKProviderReference + | URL, + mimeType: string | undefined, +): string => { + if (_isAISDKFileData(fileData)) { + if (fileData.type === "data") { + return normalizeFileDataAsDataURL(fileData.data, mimeType); + } + + if (fileData.type === "url") { + return fileData.url.toString(); + } + + if (fileData.type === "reference") { + // TODO: figure out if we can store the reference in a more reasonable format + return `data:application/octet-stream;base64,${btoa( + JSON.stringify(fileData.reference), + )}`; + } + + if (fileData.type === "text") { + return `data:text/plain;base64,${btoa(fileData.text)}`; + } + + throw new Error("AISDKFileData is not supported"); + } + + if (fileData instanceof URL) { + return fileData.toString(); + } + + if (typeof fileData !== "string") { + const uint8Array = _toUint8Array(fileData); + if (uint8Array) { + let binary = ""; + for (let i = 0; i < uint8Array.length; i++) { + binary += String.fromCharCode(uint8Array[i]); + } + const base64 = btoa(binary); + const dataType = + mimeType ?? + guessMimetypeFromBase64(base64) ?? + "application/octet-stream"; + + return `data:${dataType};base64,${base64}`; + } + } + + if (typeof fileData === "string") { + if (fileData.startsWith("http://") || fileData.startsWith("https://")) { + return fileData; + } + + if (!fileData.startsWith("data:")) { + return `data:${ + mimeType ?? + guessMimetypeFromBase64(fileData) ?? + "application/octet-stream" + };base64,${fileData}`; + } + + return fileData; + } + + return ""; +}; + +export const convertMessageToTracedFormat = ( + rawMessage: Record, + responseMetadata?: Record, +) => { + const message = rawMessage as ModelMessage; + const formattedMessage: Record = { + ...message, + }; + if (Array.isArray(message.content)) { + if (message.role === "assistant") { + const toolCallBlocks = message.content.filter( + (block): block is ToolCallPart => { + return ( + block != null && + typeof block === "object" && + block.type === "tool-call" + ); + }, + ) as ToolCallPart[]; + const toolCalls = toolCallBlocks.map((block) => { + // AI SDK 4 shim + let toolArgs = + block.input ?? (("args" in block && block.args) || undefined); + if (typeof toolArgs !== "string") { + toolArgs = JSON.stringify(toolArgs); + } + return { + id: block.toolCallId, + type: "function", + function: { + name: block.toolName, + arguments: toolArgs, + }, + }; + }); + if (toolCalls.length > 0) { + formattedMessage.tool_calls = toolCalls; + } + } + const newContent = message.content.map((part) => { + if (part.type === "file") { + const { data, mediaType, filename, ...rest } = part; + return { + ...rest, + file: { + filename, + file_data: normalizeFileDataAsDataURL(data, mediaType), + }, + }; + } else if (part.type === "image") { + const { image, mediaType, ...rest } = part; + return { + ...rest, + type: "image_url", + image_url: normalizeFileDataAsDataURL(image, mediaType), + }; + } else if ( + part.type === "reasoning" && + "text" in part && + typeof part.text === "string" + ) { + return { + type: "reasoning", + reasoning: part.text, + }; + } + return part; + }); + formattedMessage.content = newContent; + } else if (message.content == null && "text" in message) { + // AI SDK 4 shim + formattedMessage.content = message.text ?? ""; + if ( + "toolCalls" in message && + Array.isArray(message.toolCalls) && + !("tool_calls" in formattedMessage) + ) { + formattedMessage.tool_calls = message.toolCalls.map((toolCall) => { + return { + id: toolCall.toolCallId, + type: "function", + function: { name: toolCall.toolName, arguments: toolCall.args }, + }; + }); + } + } + if (responseMetadata != null) { + formattedMessage.response_metadata = responseMetadata; + } + return formattedMessage; +}; + +export const setUsageMetadataOnRunTree = ( + result: { + usage?: LanguageModelV2Usage; + providerMetadata?: SharedV2ProviderMetadata; + }, + runTree: RunTree, +) => { + if (result.usage == null || typeof result.usage !== "object") { + return; + } + + const usage = result.usage as Record; + let inputTokens: number | undefined; + let outputTokens: number | undefined; + let totalTokens: number | undefined; + + // AI SDK 6: Check for object-based token structures first + if ( + isRecord(usage.inputTokens) && + usage.inputTokens?.total != null && + typeof usage.inputTokens.total === "number" + ) { + // AI SDK 6 detected + inputTokens = usage.inputTokens.total; + + if ( + isRecord(usage.outputTokens) && + usage.outputTokens?.total != null && + typeof usage.outputTokens.total === "number" + ) { + outputTokens = usage.outputTokens.total; + } + + totalTokens = result.usage?.totalTokens; + if ( + typeof totalTokens !== "number" && + typeof inputTokens === "number" && + typeof outputTokens === "number" + ) { + totalTokens = inputTokens + outputTokens; + } + } else if (typeof usage.inputTokens === "number") { + // AI SDK 5 detected + inputTokens = usage.inputTokens; + + if (typeof usage.outputTokens === "number") { + outputTokens = usage.outputTokens; + } + + totalTokens = result.usage?.totalTokens; + if ( + typeof totalTokens !== "number" && + typeof inputTokens === "number" && + typeof outputTokens === "number" + ) { + totalTokens = inputTokens + outputTokens; + } + } else { + // AI SDK 4 fallback + if (typeof usage.promptTokens === "number") { + inputTokens = usage.promptTokens; + } + if (typeof usage.completionTokens === "number") { + outputTokens = usage.completionTokens; + } + + totalTokens = result.usage?.totalTokens; + if ( + typeof totalTokens !== "number" && + typeof inputTokens === "number" && + typeof outputTokens === "number" + ) { + totalTokens = inputTokens + outputTokens; + } + } + + const langsmithUsage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: totalTokens, + }; + const inputTokenDetails = extractInputTokenDetails( + result.usage, + result.providerMetadata, + ); + const outputTokenDetails = extractOutputTokenDetails( + result.usage, + result.providerMetadata, + ); + runTree.extra = { + ...runTree.extra, + metadata: { + ...runTree.extra?.metadata, + usage_metadata: { + ...langsmithUsage, + input_token_details: { + ...inputTokenDetails, + }, + output_token_details: { + ...outputTokenDetails, + }, + }, + }, + }; +}; diff --git a/src/lib/experimental/vercel/wrap.ts b/src/lib/experimental/vercel/wrap.ts new file mode 100644 index 0000000..002709d --- /dev/null +++ b/src/lib/experimental/vercel/wrap.ts @@ -0,0 +1,1068 @@ +import type { JSONValue } from "ai"; +import type { + LanguageModelV2, + LanguageModelV2CallOptions, +} from "@ai-sdk/provider"; + +import { + type AggregatedDoStreamOutput, + LangSmithMiddleware, +} from "./middleware.js"; +import { convertMessageToTracedFormat } from "./utils.js"; +import { isTraceableFunction, traceable } from "../../traceable.js"; +import type { RunTreeConfig } from "../../run_trees.js"; +import { vercelLsAgentTypeMetadata } from "./_agent_type.js"; + +const _getModelDisplayName = ( + model: string | Record, +): string => { + if (typeof model === "string") { + return model; + } + + if ( + model.config != null && + typeof model.config === "object" && + "provider" in model.config && + typeof model.config.provider === "string" + ) { + return model.config.provider; + } + + if (model.modelId != null && typeof model.modelId === "string") { + return model.modelId; + } + + return "unknown"; +}; + +const _getModelId = (model: string | Record) => { + if (typeof model === "string") { + return model; + } + return typeof model.modelId === "string" ? model.modelId : undefined; +}; + +const _wrapTools = ( + tools?: Record, + lsConfig?: Partial, +) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrappedTools: Record = {}; + if (tools) { + for (const [key, tool] of Object.entries(tools)) { + wrappedTools[key] = Object.assign( + Object.create(Object.getPrototypeOf(tool)), + tool, + ); + if ( + wrappedTools[key] != null && + typeof wrappedTools[key] === "object" && + "execute" in wrappedTools[key] && + typeof wrappedTools[key].execute === "function" && + !isTraceableFunction(wrappedTools[key].execute) + ) { + wrappedTools[key].execute = traceable( + wrappedTools[key].execute.bind(wrappedTools[key]), + { + ...lsConfig, + name: key, + run_type: "tool", + }, + ); + } + } + } + return wrappedTools; +}; + +const _formatTracedInputs = async (params: Record) => { + const { prompt, messages, model, tools, output, ...rest } = params; + let processedInputs: Record = {}; + if (Array.isArray(prompt)) { + processedInputs = { + ...rest, + messages: prompt.map((message) => convertMessageToTracedFormat(message)), + }; + } else if (Array.isArray(messages)) { + processedInputs = { + ...rest, + messages: messages.map((message) => + convertMessageToTracedFormat(message), + ), + }; + } else { + processedInputs = { ...rest, prompt, messages }; + } + try { + if ( + output != null && + typeof output === "object" && + "responseFormat" in output + ) { + const responseFormat = await output.responseFormat; + processedInputs.output = responseFormat; + } else { + processedInputs.output = output; + } + } catch { + // Could not extract response format from output for tracing + processedInputs.output = output; + } + return processedInputs; +}; + +const _mergeConfig = ( + baseConfig?: Partial, + runtimeConfig?: Partial, +): Record => { + return { + ...baseConfig, + ...runtimeConfig, + metadata: { + ls_integration: "vercel-ai-sdk", + ...baseConfig?.metadata, + ...runtimeConfig?.metadata, + }, + }; +}; + +export type { AggregatedDoStreamOutput }; + +export type WrapAISDKConfig< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends (...args: any[]) => any = (...args: any[]) => any, +> = Partial< + Omit< + RunTreeConfig, + | "inputs" + | "outputs" + | "run_type" + | "child_runs" + | "parent_run" + | "error" + | "serialized" + > +> & { + /** + * Apply transformations to AI SDK inputs before logging. + * This function should NOT mutate the inputs. + * Receives both "raw" and LangSmith-suggested "formatted" inputs, + * and should combine them into a single LangSmith-formatted input. + * + * ```ts + * import { + * wrapAISDK, + * createLangSmithProviderOptions, + * } from "langsmith/experimental/vercel"; + * import * as ai from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * const { generateText } = wrapAISDK(ai); + * + * const lsConfig = createLangSmithProviderOptions({ + * processInputs: (inputs) => { + * const { messages } = inputs; + * return { + * messages: messages?.map((message) => ({ + * providerMetadata: message.providerOptions, + * role: "assistant", + * content: "REDACTED", + * })), + * prompt: "REDACTED", + * }; + * }, + * }); + * const { text } = await generateText({ + * model: openai("gpt-5-nano"), + * prompt: "What is the capital of France?", + * providerOptions: { + * langsmith: lsConfig, + * }, + * }); + * ``` + * + * This function is not inherited by nested LLM runs or tool calls. + * Pass `processChildLLMRunInputs` to override child LLM run + * input processing or wrap your tool's `execute` method in a + * separate `traceable` for tool calls. + * + * @param inputs Key-value map of the function inputs. + * @param inputs.formatted - Inputs formatted for LangSmith. + * @param inputs.raw - Raw inputs from the AI SDK. + * @returns A single combined key-value map of processed inputs. + */ + processInputs?: (inputs: Parameters[0]) => Record; + /** + * Apply transformations to AI SDK outputs before logging. + * This function should NOT mutate the outputs. + * Receives both "raw" and LangSmith-suggested "formatted" outputs, + * and should combine them into a single LangSmith-formatted output. + * + * ```ts + * import { + * wrapAISDK, + * createLangSmithProviderOptions, + * } from "langsmith/experimental/vercel"; + * import * as ai from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * const { generateText } = wrapAISDK(ai); + * + * const lsConfig = createLangSmithProviderOptions({ + * processOutputs: (outputs) => { + * return { + * providerMetadata: outputs.providerMetadata, + * role: "assistant", + * content: "REDACTED", + * }; + * }, + * }); + * const { text } = await generateText({ + * model: openai("gpt-5-nano"), + * prompt: "What is the capital of France?", + * providerOptions: { + * langsmith: lsConfig, + * }, + * }); + * ``` + * + * This function is not inherited by nested LLM runs or tool calls. + * Pass `processChildLLMRunOutputs` to override child LLM run + * output processing or wrap your tool's `execute` method in a + * separate `traceable` for tool calls. + * + * @param outputs Key-value map of the function inputs. + * @param outputs.formatted - Outputs formatted for LangSmith. + * @param outputs.raw - Raw outputs from the AI SDK. + * @returns A single combined key-value map of processed outputs. + */ + processOutputs?: ( + // TODO: Unnest this typing on minor bump + outputs: { + outputs: Awaited>; + }, + ) => Record | Promise>; + /** + * Apply transformations to AI SDK child LLM run inputs before logging. + * This function should NOT mutate the inputs. + * Receives both "raw" and LangSmith-suggested "formatted" inputs, + * and should combine them into a single LangSmith-formatted input. + * + * ```ts + * import { + * wrapAISDK, + * createLangSmithProviderOptions, + * } from "langsmith/experimental/vercel"; + * import * as ai from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * const { generateText } = wrapAISDK(ai); + * + * const lsConfig = createLangSmithProviderOptions({ + * processChildLLMRunInputs: (inputs) => { + * const { prompt } = inputs; + * return { + * messages: prompt.map((message) => ({ + * ...message, + * content: "REDACTED CHILD INPUTS", + * })), + * }; + * }, + * }); + * const { text } = await generateText({ + * model: openai("gpt-5-nano"), + * prompt: "What is the capital of France?", + * providerOptions: { + * langsmith: lsConfig, + * }, + * }); + * ``` + * + * @param inputs Key-value map of the function inputs. + * @param inputs.formatted - Inputs formatted for LangSmith. + * @param inputs.raw - Raw inputs from the AI SDK. + * @returns A single combined key-value map of processed inputs. + */ + processChildLLMRunInputs?: ( + inputs: LanguageModelV2CallOptions, + ) => Record; + /** + * Apply transformations to AI SDK child LLM run outputs before logging. + * This function should NOT mutate the outputs. + * Receives both "raw" and LangSmith-suggested "formatted" outputs, + * and should combine them into a single LangSmith-formatted output. + * + * ```ts + * import { + * wrapAISDK, + * createLangSmithProviderOptions, + * } from "langsmith/experimental/vercel"; + * import * as ai from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * const { generateText } = wrapAISDK(ai); + * + * const lsConfig = createLangSmithProviderOptions({ + * processChildLLMRunOutputs: (outputs) => { + * return { + * providerMetadata: outputs.providerMetadata, + * content: "REDACTED CHILD OUTPUTS", + * role: "assistant", + * }; + * }, + * }); + * const { text } = await generateText({ + * model: openai("gpt-5-nano"), + * prompt: "What is the capital of France?", + * providerOptions: { + * langsmith: lsConfig, + * }, + * }); + * ``` + * + * @param outputs Key-value map of the function inputs. + * @param outputs.formatted - Outputs formatted for LangSmith. + * @param outputs.raw - Raw outputs from the AI SDK. + * @returns A single combined key-value map of processed outputs. + */ + processChildLLMRunOutputs?: ( + outputs: "fullStream" extends keyof Awaited> + ? AggregatedDoStreamOutput + : Awaited>, + ) => Record; + + /** + * Whether to include additional fields such as intermediate steps in traced + * output messages. + * @default false + */ + traceResponseMetadata?: boolean; + + /** + * Whether to include raw HTTP request and response details in traces from the + * underlying model calls (doGenerate/doStream). + * + * When enabled, traces will include the full HTTP request body, response body, + * headers, and other low-level details. This can be useful for debugging provider + * issues but creates very verbose traces. + * + * @default false + */ + traceRawHttp?: boolean; +}; + +const _extractChildRunConfig = (lsConfig?: WrapAISDKConfig) => { + const { + id, + name, + parent_run_id, + start_time, + end_time, + attachments, + dotted_order, + processInputs, + processOutputs, + processChildLLMRunInputs, + processChildLLMRunOutputs, + ...inheritedConfig + } = lsConfig ?? {}; + const childConfig: WrapAISDKConfig = inheritedConfig; + if (processChildLLMRunInputs) { + childConfig.processInputs = processChildLLMRunInputs; + } + if (processChildLLMRunOutputs) { + // TODO: Fix this typing on minor bump + // eslint-disable-next-line @typescript-eslint/no-explicit-any + childConfig.processOutputs = processChildLLMRunOutputs as any; + } + return childConfig; +}; + +const _resolveConfigs = ( + baseLsConfig?: WrapAISDKConfig, + runtimeLsConfig?: WrapAISDKConfig, +): { + resolvedLsConfig: WrapAISDKConfig; + resolvedChildLLMRunConfig: WrapAISDKConfig; + resolvedToolConfig: WrapAISDKConfig; +} => { + const baseChildRunConfig = _extractChildRunConfig(baseLsConfig); + const runtimeChildLLMRunConfig = _extractChildRunConfig(runtimeLsConfig); + const resolvedLsConfig = _mergeConfig(baseLsConfig, runtimeLsConfig); + const resolvedChildLLMRunConfig = _mergeConfig( + baseChildRunConfig, + runtimeChildLLMRunConfig, + ); + const { + processInputs: _processInputs, + processOutputs: _processOutputs, + ...resolvedToolConfig + } = resolvedChildLLMRunConfig; + + return { + resolvedLsConfig, + resolvedChildLLMRunConfig, + resolvedToolConfig, + }; +}; + +const _getGenerateTextWrapperConfig = ({ + model, + runName, + aiSdkMethodName, + resolvedLsConfig, + hasExplicitOutput, + hasExplicitExperimentalOutput, + traceResponseMetadata, +}: { + model: any; + runName?: string; + aiSdkMethodName?: string; + resolvedLsConfig: WrapAISDKConfig; + hasExplicitOutput: boolean; + hasExplicitExperimentalOutput?: boolean; + traceResponseMetadata?: boolean; +}) => { + return { + name: runName ?? _getModelDisplayName(model), + ...resolvedLsConfig, + metadata: { + ...vercelLsAgentTypeMetadata(), + ai_sdk_method: aiSdkMethodName ?? "ai.generateText", + ...resolvedLsConfig?.metadata, + }, + processInputs: async (inputs: any) => { + const inputFormatter = + resolvedLsConfig?.processInputs ?? _formatTracedInputs; + return inputFormatter(inputs); + }, + processOutputs: async (outputs: any) => { + if (resolvedLsConfig?.processOutputs) { + const processedOutputs = await resolvedLsConfig.processOutputs( + // TODO: Fix this typing on minor bump + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs as any, + ); + return processedOutputs; + } + if (outputs.outputs == null || typeof outputs.outputs !== "object") { + return outputs; + } + // If output or experimental_output (legacy) was explicitly provided, return it directly at top level (like generateObject) + // Note: In AI SDK 6, experimental_output/output is always available as a getter, so we need to check if it was explicitly provided + if (hasExplicitOutput) { + try { + // Try new 'output' property first, then fall back to 'experimental_output' for backwards compatibility + if ("output" in outputs.outputs) { + const output = outputs.outputs.output; + if (output != null && typeof output === "object") { + if (Array.isArray(output)) { + return { outputs: output }; + } + return output; + } + } + } catch { + // output not accessible, continue with normal processing + } + } else if (hasExplicitExperimentalOutput) { + try { + if ("experimental_output" in outputs.outputs) { + const experimentalOutput = outputs.outputs.experimental_output; + if (experimentalOutput != null) { + return experimentalOutput; + } + } + } catch { + // experimental_output not accessible, continue with normal processing + } + } + const { steps } = outputs.outputs; + if (Array.isArray(steps)) { + const lastStep = steps.at(-1); + if (lastStep == null || typeof lastStep !== "object") { + return outputs; + } + const { content } = lastStep; + return convertMessageToTracedFormat( + { + content: content ?? outputs.outputs.text, + role: "assistant", + }, + resolvedLsConfig?.traceResponseMetadata ?? traceResponseMetadata + ? { steps } + : undefined, + ); + } else { + return outputs; + } + }, + }; +}; + +const _getStreamTextWrapperConfig = ({ + model, + runName, + aiSdkMethodName, + resolvedLsConfig, + hasExplicitOutput, + hasExplicitExperimentalOutput, + traceResponseMetadata, +}: { + model: any; + runName?: string; + aiSdkMethodName?: string; + resolvedLsConfig: WrapAISDKConfig; + hasExplicitOutput: boolean; + hasExplicitExperimentalOutput?: boolean; + traceResponseMetadata?: boolean; +}) => { + return { + name: runName ?? _getModelDisplayName(model), + ...resolvedLsConfig, + metadata: { + ...vercelLsAgentTypeMetadata(), + ai_sdk_method: aiSdkMethodName ?? "ai.streamText", + ...resolvedLsConfig?.metadata, + }, + processInputs: async (inputs: any) => { + const inputFormatter = + resolvedLsConfig?.processInputs ?? _formatTracedInputs; + return inputFormatter(inputs); + }, + processOutputs: async (outputs: any) => { + try { + if (resolvedLsConfig?.processOutputs) { + const processedOutputs = await resolvedLsConfig.processOutputs( + // TODO: Fix this typing on minor bump + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs as any, + ); + return processedOutputs; + } + if (outputs.outputs == null || typeof outputs.outputs !== "object") { + return outputs; + } + // Important: Even accessing this property creates a promise. + // This must be awaited. + let content = await outputs.outputs.content; + if (content == null) { + // AI SDK 4 shim + content = await outputs.outputs.text; + } + if (content == null || !["object", "string"].includes(typeof content)) { + return outputs; + } + try { + if (hasExplicitOutput || hasExplicitExperimentalOutput) { + const textContent = await outputs.outputs.text; + return JSON.parse(textContent); + } + } catch { + // experimental_partialOutputStream not specified, continue with normal processing + } + let responseMetadata: Record | undefined = undefined; + if (resolvedLsConfig?.traceResponseMetadata ?? traceResponseMetadata) { + try { + const steps = await outputs.outputs.steps; + responseMetadata = { steps }; + } catch { + // Do nothing if step parsing fails + } + } + return convertMessageToTracedFormat( + { + content, + role: "assistant", + }, + responseMetadata, + ); + } catch { + // Handle parsing failures without a log + return outputs; + } + }, + }; +}; + +/** + * Wraps LangSmith config in a way that matches AI SDK provider types. + * + * ```ts + * import { createLangSmithProviderOptions } from "langsmith/experimental/vercel"; + * import * as ai from "ai"; + * + * const lsConfig = createLangSmithProviderOptions({ + * // Will have appropriate typing + * processInputs: (inputs) => { + * const { messages } = inputs; + * return { + * messages: messages?.map((message) => ({ + * ...message, + * content: "REDACTED", + * })), + * prompt: "REDACTED", + * }; + * }, + * }); + * ``` + * + * Note: AI SDK expects only JSON values in an object for + * provider options, but LangSmith's config may contain non-JSON values. + * These are not passed to the underlying AI SDK model, so it is safe to + * cast the typing here. + */ +export const createLangSmithProviderOptions = < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends (...args: any[]) => any, +>( + lsConfig?: WrapAISDKConfig, +) => { + return (lsConfig ?? {}) as Record; +}; + +/** + * Wraps Vercel AI SDK 6 or AI SDK 5 functions with LangSmith tracing capabilities. + * + * @param methods - Object containing AI SDK methods to wrap + * @param methods.wrapLanguageModel - AI SDK's wrapLanguageModel function + * @param methods.generateText - AI SDK's generateText function + * @param methods.streamText - AI SDK's streamText function + * @param methods.streamObject - AI SDK's streamObject function + * @param methods.generateObject - AI SDK's generateObject function + * + * @returns Object containing wrapped versions of the AI SDK functions with LangSmith tracing + * @returns returns.generateText - Wrapped generateText function that traces calls to LangSmith + * @returns returns.generateObject - Wrapped generateObject function that traces calls to LangSmith + * @returns returns.streamText - Wrapped streamText function that traces calls to LangSmith + * @returns returns.streamObject - Wrapped streamObject function that traces calls to LangSmith + */ +const wrapAISDK = < + T extends { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + wrapLanguageModel: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateText: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + streamText: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + streamObject?: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateObject?: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ToolLoopAgent?: any; + }, +>( + ai: T, + baseLsConfig?: WrapAISDKConfig, +) => { + /** + * Wrapped version of AI SDK's generateText with LangSmith tracing. + * + * This function has the same signature and behavior as the original generateText, + * but adds automatic tracing to LangSmith for observability. + * + * ```ts + * import * as ai from "ai"; + * import { wrapAISDK } from "langsmith/experimental/vercel"; + * + * const { generateText } = wrapAISDK(ai); + * const { text } = await generateText(...); + * ``` + * + * @see {@link https://sdk.vercel.ai/docs/ai-sdk-core/generating-text} Original generateText documentation + * @param params - Same parameters as the original generateText function + * @returns Promise resolving to the same result as generateText, with tracing applied + */ + const wrappedGenerateText = async ( + ...args: Parameters + ) => { + const params = args[0]; + const { langsmith: runtimeLsConfig, ...providerOptions } = + params.providerOptions ?? {}; + const { resolvedLsConfig, resolvedChildLLMRunConfig, resolvedToolConfig } = + _resolveConfigs(baseLsConfig, runtimeLsConfig); + const hasExplicitOutput = "output" in params; + const hasExplicitExperimentalOutput = "experimental_output" in params; + const traceableFunc = traceable( + async ( + ...args: Parameters + ): Promise> => { + const [params, ...rest] = args; + const wrappedModel = ai.wrapLanguageModel({ + model: params.model, + middleware: LangSmithMiddleware({ + name: _getModelDisplayName(params.model), + modelId: _getModelId(params.model), + // TODO: Fix this typing on minor bump + lsConfig: resolvedChildLLMRunConfig as Record, + }), + }); + return ai.generateText( + { + ...params, + providerOptions, + tools: _wrapTools(params.tools, resolvedToolConfig), + model: wrappedModel, + }, + ...rest, + ); + }, + _getGenerateTextWrapperConfig({ + model: params.model, + resolvedLsConfig, + hasExplicitOutput, + hasExplicitExperimentalOutput, + }), + ) as ( + ...args: Parameters + ) => Promise>; + return traceableFunc(...args); + }; + + let wrappedGenerateObject; + if (typeof ai.generateObject === "function") { + const generateObject = ai.generateObject; + /** + * Wrapped version of AI SDK's generateObject with LangSmith tracing. + * + * This function has the same signature and behavior as the original generateObject, + * but adds automatic tracing to LangSmith for observability. + * + * ```ts + * import * as ai from "ai"; + * import { wrapAISDK } from "langsmith/experimental/vercel"; + * + * const { generateObject } = wrapAISDK(ai); + * const { object } = await generateObject(...); + * ``` + * + * @see {@link https://sdk.vercel.ai/docs/ai-sdk-core/generating-structured-data} Original generateObject documentation + * @param params - Same parameters as the original generateObject function + * @returns Promise resolving to the same result as generateObject, with tracing applied + */ + wrappedGenerateObject = async ( + ...args: Parameters + ) => { + const params = args[0]; + const { langsmith: runtimeLsConfig, ...providerOptions } = + params.providerOptions ?? {}; + const { resolvedLsConfig, resolvedChildLLMRunConfig } = _resolveConfigs( + baseLsConfig, + runtimeLsConfig, + ); + const traceableFunc = traceable( + async ( + ...args: Parameters + ): Promise> => { + const [params, ...rest] = args; + const wrappedModel = ai.wrapLanguageModel({ + model: params.model, + middleware: LangSmithMiddleware({ + name: _getModelDisplayName(params.model), + modelId: _getModelId(params.model), + // TODO: Fix this typing on minor bump + lsConfig: resolvedChildLLMRunConfig as Record, + }), + }); + return generateObject( + { + ...params, + providerOptions, + model: wrappedModel, + }, + ...rest, + ) as ReturnType; + }, + { + name: _getModelDisplayName(params.model), + ...resolvedLsConfig, + metadata: { + ...vercelLsAgentTypeMetadata(), + ai_sdk_method: "ai.generateObject", + ...resolvedLsConfig?.metadata, + }, + processInputs: async (inputs) => { + const inputFormatter = + resolvedLsConfig?.processInputs ?? _formatTracedInputs; + return inputFormatter(inputs); + }, + processOutputs: async (outputs) => { + if (resolvedLsConfig?.processOutputs) { + const processedOutputs = await resolvedLsConfig.processOutputs( + // TODO: Fix this typing on minor bump + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs as any, + ); + return processedOutputs; + } + if ( + outputs.outputs == null || + typeof outputs.outputs !== "object" + ) { + return outputs; + } + return outputs.outputs.object ?? outputs; + }, + }, + ) as ( + ...args: Parameters + ) => Promise>; + return traceableFunc(...args); + }; + } + + /** + * Wrapped version of AI SDK's streamText with LangSmith tracing. + * + * Must be called with `await`, but otherwise behaves the same as the + * original streamText and adds adds automatic tracing to LangSmith + * for observability. + * + * ```ts + * import * as ai from "ai"; + * import { wrapAISDK } from "langsmith/experimental/vercel"; + * + * const { streamText } = wrapAISDK(ai); + * const { textStream } = await streamText(...); + * ``` + * + * @see {@link https://sdk.vercel.ai/docs/ai-sdk-core/generating-text} Original streamText documentation + * @param params - Same parameters as the original streamText function + * @returns Promise resolving to the same result as streamText, with tracing applied + */ + const wrappedStreamText = (...args: Parameters) => { + const params = args[0]; + const { langsmith: runtimeLsConfig, ...providerOptions } = + params.providerOptions ?? {}; + const { resolvedLsConfig, resolvedChildLLMRunConfig, resolvedToolConfig } = + _resolveConfigs(baseLsConfig, runtimeLsConfig); + const hasExplicitOutput = "output" in params; + const hasExplicitExperimentalOutput = "experimental_output" in params; + const traceableFunc = traceable( + ( + ...args: Parameters + ): ReturnType => { + const [params, ...rest] = args; + const wrappedModel = ai.wrapLanguageModel({ + model: params.model, + middleware: LangSmithMiddleware({ + name: _getModelDisplayName(params.model), + modelId: _getModelId(params.model), + // TODO: Fix this typing on minor bump + lsConfig: resolvedChildLLMRunConfig as Record, + }), + }); + return ai.streamText( + { + ...params, + providerOptions, + tools: _wrapTools(params.tools, resolvedToolConfig), + model: wrappedModel, + }, + ...rest, + ) as ReturnType; + }, + _getStreamTextWrapperConfig({ + model: params.model, + resolvedLsConfig, + hasExplicitOutput, + hasExplicitExperimentalOutput, + }), + ) as ( + ...args: Parameters + ) => ReturnType; + return traceableFunc(...args); + }; + + let wrappedStreamObject; + if (typeof ai.streamObject === "function") { + const streamObject = ai.streamObject; + /** + * Wrapped version of AI SDK's streamObject with LangSmith tracing. + * + * Must be called with `await`, but otherwise behaves the same as the + * original streamObject and adds adds automatic tracing to LangSmith + * for observability. + * + * ```ts + * import * as ai from "ai"; + * import { wrapAISDK } from "langsmith/experimental/vercel"; + * + * const { streamObject } = wrapAISDK(ai); + * const { partialObjectStream } = await streamObject(...); + * ``` + * + * @see {@link https://sdk.vercel.ai/docs/ai-sdk-core/generating-structured-data} Original streamObject documentation + * @param params - Same parameters as the original streamObject function + * @returns Promise resolving to the same result as streamObject, with tracing applied + */ + wrappedStreamObject = (...args: Parameters) => { + const params = args[0]; + const { langsmith: runtimeLsConfig, ...providerOptions } = + params.providerOptions ?? {}; + const { resolvedLsConfig, resolvedChildLLMRunConfig } = _resolveConfigs( + baseLsConfig, + runtimeLsConfig, + ); + const traceableFunc = traceable( + ( + ...args: Parameters + ): ReturnType => { + const [params, ...rest] = args; + const wrappedModel = ai.wrapLanguageModel({ + model: params.model, + middleware: LangSmithMiddleware({ + name: _getModelDisplayName(params.model), + modelId: _getModelId(params.model), + // TODO: Fix this typing on minor bump + lsConfig: resolvedChildLLMRunConfig as Record, + }), + }); + return streamObject( + { + ...params, + providerOptions, + model: wrappedModel, + }, + ...rest, + ) as ReturnType; + }, + { + name: _getModelDisplayName(params.model), + ...resolvedLsConfig, + metadata: { + ...vercelLsAgentTypeMetadata(), + ai_sdk_method: "ai.streamObject", + ...resolvedLsConfig?.metadata, + }, + processInputs: async (inputs) => { + const inputFormatter = + resolvedLsConfig?.processInputs ?? _formatTracedInputs; + return inputFormatter(inputs); + }, + processOutputs: async (outputs) => { + try { + if (resolvedLsConfig?.processOutputs) { + const processedOutputs = await resolvedLsConfig.processOutputs( + // TODO: Fix this typing on minor bump + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs as any, + ); + return processedOutputs; + } + if ( + outputs.outputs == null || + typeof outputs.outputs !== "object" + ) { + return outputs; + } + const object = await outputs.outputs.object; + if (object == null || typeof object !== "object") { + return outputs; + } + return object; + } catch { + // Handle parsing failures without a log + return outputs; + } + }, + }, + ) as ( + ...args: Parameters + ) => ReturnType; + return traceableFunc(...args); + }; + } + + let wrappedToolLoopAgentClass; + if (ai.ToolLoopAgent != null) { + try { + const wrapToolLoopAgent = (ToolLoopAgent: any) => { + return new Proxy(ToolLoopAgent, { + construct(ToolLoopAgent, args) { + const params = args[0] ?? {}; + const { langsmith: runtimeLsConfig } = params.providerOptions ?? {}; + const { + resolvedLsConfig, + resolvedChildLLMRunConfig, + resolvedToolConfig, + } = _resolveConfigs(baseLsConfig, runtimeLsConfig); + let wrappedModel = params.model; + if (wrappedModel != null) { + wrappedModel = ai.wrapLanguageModel({ + model: params.model, + middleware: LangSmithMiddleware({ + name: _getModelDisplayName(params.model), + modelId: _getModelId(params.model), + // TODO: Fix this typing on minor bump + lsConfig: resolvedChildLLMRunConfig as Record< + string, + unknown + >, + }), + }); + } + let wrappedTools = params.tools; + if (wrappedTools != null) { + wrappedTools = _wrapTools(params.tools, resolvedToolConfig); + } + const instance = new ToolLoopAgent( + ...[ + { ...params, model: wrappedModel, tools: wrappedTools }, + ...args.slice(1), + ], + ); + if (typeof instance.generate === "function") { + instance.generate = traceable( + instance.generate.bind(instance), + _getGenerateTextWrapperConfig({ + model: wrappedModel, + runName: "ToolLoopAgent", + aiSdkMethodName: "ai.ToolLoopAgent.generate", + resolvedLsConfig, + hasExplicitOutput: + "output" in params && params.output != null, + traceResponseMetadata: true, + }), + ); + } + if (typeof instance.stream === "function") { + instance.stream = traceable( + instance.stream.bind(instance), + _getStreamTextWrapperConfig({ + model: wrappedModel, + runName: "ToolLoopAgent", + aiSdkMethodName: "ai.ToolLoopAgent.stream", + resolvedLsConfig, + hasExplicitOutput: + "output" in params && params.output != null, + traceResponseMetadata: true, + }), + ); + } + return instance; + }, + }); + }; + wrappedToolLoopAgentClass = wrapToolLoopAgent(ai.ToolLoopAgent); + } catch (e) { + console.error("Failed to wrap passed ToolLoopAgent:", e); + wrappedToolLoopAgentClass = ai.ToolLoopAgent; + } + } + + return { + ...ai, + generateText: wrappedGenerateText, + generateObject: wrappedGenerateObject, + streamText: wrappedStreamText, + streamObject: wrappedStreamObject, + ToolLoopAgent: wrappedToolLoopAgentClass, + } as T; +}; + +export { wrapAISDK }; + +export { convertMessageToTracedFormat }; diff --git a/src/lib/index.ts b/src/lib/index.ts new file mode 100644 index 0000000..c040a41 --- /dev/null +++ b/src/lib/index.ts @@ -0,0 +1,59 @@ +export { + Client, + type ClientConfig, + type LangSmithTracingClientInterface, +} from "./client.js"; + +export type { + Dataset, + Example, + TracerSession, + Run, + Feedback, + FeedbackConfigSchema, + RetrieverOutput, +} from "./schemas.js"; + +export { RunTree, type RunTreeConfig, type WriteReplica } from "./run_trees.js"; + +export { overrideFetchImplementation } from "./singletons/fetch.js"; + +export { getDefaultProjectName } from "./utils/project.js"; + +export { + computeRunIdForSecondaryReplica, + uuid7, + uuid7FromTime, +} from "./uuid.js"; + +export { isTracingEnabled } from "./utils/guard.js"; + +export { + Cache, + PromptCache, + type CacheConfig, + type CacheMetrics, + configureGlobalPromptCache, + promptCacheSingleton, +} from "./utils/prompt_cache/index.js"; + +export { + LangsmithError, + APIError, + APIUserAbortError, + APIConnectionError, + APIConnectionTimeoutError, + BadRequestError, + AuthenticationError, + PermissionDeniedError, + NotFoundError, + ConflictError, + UnprocessableEntityError, + RateLimitError, + InternalServerError, +} from "../core/error.js"; + +export { VERSION as __version__ } from "../version.js"; + +// Metadata key to hide a traced run from LangSmith's Messages View. +export const LS_MESSAGE_VIEW_EXCLUDE = "ls_message_view_exclude" as const; diff --git a/src/lib/jest/index.ts b/src/lib/jest/index.ts new file mode 100644 index 0000000..f2fe7aa --- /dev/null +++ b/src/lib/jest/index.ts @@ -0,0 +1,504 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import { + expect as jestExpect, + test as jestTest, + it as jestIt, + describe as jestDescribe, + beforeAll as jestBeforeAll, + afterAll as jestAfterAll, + beforeEach as jestBeforeEach, + afterEach as jestAfterEach, +} from "@jest/globals"; +import { + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, + type AbsoluteCloseToMatcherOptions, + type SemanticCloseToMatcherOptions, + type RelativeCloseToMatcherOptions, +} from "../utils/jestlike/matchers.js"; +import type { SimpleEvaluator } from "../utils/jestlike/vendor/evaluatedBy.js"; +import { wrapEvaluator } from "../utils/jestlike/vendor/evaluatedBy.js"; +import { logFeedback, logOutputs } from "../utils/jestlike/index.js"; +import { generateWrapperFromJestlikeMethods } from "../utils/jestlike/index.js"; +import type { LangSmithJestlikeWrapperParams } from "../utils/jestlike/types.js"; + +jestExpect.extend({ + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +}); + +declare global { + namespace jest { + interface AsymmetricMatchers { + toBeRelativeCloseTo( + expected: string, + options?: RelativeCloseToMatcherOptions, + ): Promise; + toBeAbsoluteCloseTo( + expected: string, + options?: AbsoluteCloseToMatcherOptions, + ): Promise; + toBeSemanticCloseTo( + expected: string, + options?: SemanticCloseToMatcherOptions, + ): Promise; + } + interface Matchers { + toBeRelativeCloseTo( + expected: string, + options?: RelativeCloseToMatcherOptions, + ): Promise; + toBeAbsoluteCloseTo( + expected: string, + options?: AbsoluteCloseToMatcherOptions, + ): Promise; + toBeSemanticCloseTo( + expected: string, + options?: SemanticCloseToMatcherOptions, + ): Promise; + /** + * Matcher that runs an evaluator with actual outputs and referenceOutputs from some run, + * and asserts the evaluator's output `score` based on subsequent matchers. + * Will also log feedback to LangSmith and to test results. + * + * Inputs come from the inputs passed to the test. + * + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * const myEvaluator = async ({ inputs, outputs, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * await ls.expect(response).evaluatedBy(myEvaluator).toBeGreaterThan(0.5); + * return { response }; + * } + * ); + * }); + * ``` + */ + evaluatedBy(evaluator: SimpleEvaluator): jest.Matchers> & { + not: jest.Matchers>; + resolves: jest.Matchers>; + rejects: jest.Matchers>; + }; + } + } +} + +/** + * Dynamically wrap original Jest imports. + * + * This may be necessary to ensure you are wrapping the correct + * Jest version if you are using a monorepo whose workspaces + * use multiple versions of Jest. + * + * @param originalJestMethods - The original Jest imports to wrap. + * @returns The wrapped Jest imports. + * See https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest + * for more details. + */ +const wrapJest = (originalJestMethods: Record) => { + if (typeof originalJestMethods !== "object" || originalJestMethods == null) { + throw new Error("originalJestMethods must be an non-null object."); + } + if ( + !("expect" in originalJestMethods) || + typeof originalJestMethods.expect !== "function" + ) { + throw new Error("Your passed object must contain a `expect` method."); + } + if ( + !("it" in originalJestMethods) || + typeof originalJestMethods.it !== "function" + ) { + throw new Error("Your passed object must contain a `it` method."); + } + if ( + !("test" in originalJestMethods) || + typeof originalJestMethods.test !== "function" + ) { + throw new Error("Your passed object must contain a `test` method."); + } + if ( + !("describe" in originalJestMethods) || + typeof originalJestMethods.describe !== "function" + ) { + throw new Error("Your passed object must contain a `describe` method."); + } + if ( + !("beforeAll" in originalJestMethods) || + typeof originalJestMethods.beforeAll !== "function" + ) { + throw new Error("Your passed object must contain a `beforeAll` method."); + } + if ( + !("afterAll" in originalJestMethods) || + typeof originalJestMethods.afterAll !== "function" + ) { + throw new Error("Your passed object must contain a `afterAll` method."); + } + + const wrappedMethods = generateWrapperFromJestlikeMethods( + { + expect: originalJestMethods.expect, + it: originalJestMethods.it, + test: originalJestMethods.test, + describe: originalJestMethods.describe, + beforeAll: originalJestMethods.beforeAll, + afterAll: originalJestMethods.afterAll, + logFeedback, + logOutputs, + wrapEvaluator, + }, + process?.versions?.bun !== undefined ? "bun" : "jest", + ); + + // Return the normal used LS methods for convenience + // so that you can do: + // + // const ls = wrapJest(jest); + // ls.logFeedback({ key: "quality", score: 0.7 }); + return { + ...wrappedMethods, + logFeedback, + logOutputs, + wrapEvaluator, + }; +}; + +const { test, it, describe, expect } = wrapJest({ + expect: jestExpect, + it: jestIt, + test: jestTest, + describe: jestDescribe, + beforeAll: jestBeforeAll, + afterAll: jestAfterAll, + beforeEach: jestBeforeEach, + afterEach: jestAfterEach, +}); + +export { + /** + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + test, + /** + * Alias of `ls.test()`. + * + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.it( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.it.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + it, + /** + * Defines a LangSmith test suite. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. + * + * Should contain `ls.test()` cases within. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test suite + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {Partial} [config] - Config to use when tracing/sending results. + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + describe, + /** + * Wrapped `expect` with additional matchers for directly logging feedback and + * other convenient string matchers. + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * const myEvaluator = async ({ inputs, outputs, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will assert evaluator's returned score + * // and log feedback. + * await ls.expect(response).evaluatedBy(myEvaluator).toBeGreaterThan(0.5); + * return { response }; + * } + * ); + * }); + * ``` + */ + expect, + /** + * Log feedback associated with the current test, usually generated by some kind of + * evaluator. + * + * Logged feedback will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * }); + * ``` + */ + logFeedback, + /** + * Log output associated with the current test. + * + * Logged output will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * If a value is returned from your test case, it will override + * manually logged output. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * ls.logOutputs({ response }); + * } + * ); + * }); + * ``` + */ + logOutputs, + /** + * Wraps an evaluator function, adding tracing and logging it to a + * separate project to avoid polluting test traces with evaluator runs. + * + * The wrapped evaluator must take only a single argument as input. + * + * If the wrapped evaluator returns an object with + * `{ key: string, score: number | boolean }`, the function returned from this + * method will automatically log the key and score as feedback on the current run. + * Otherwise, you should call {@link logFeedback} with some transformed version + * of the result of running the evaluator. + * + * @param {Function} evaluator The evaluator to be wrapped. Must take only a single argument as input. + * + * @example + * ```ts + * import * as ls from "langsmith/jest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will log the evaluator's returned score + * // and as feedback under the returned key. + * const wrappedEvaluator = ls.wrapEvaluator(myEvaluator); + * await wrappedEvaluator({ inputs, referenceOutputs, actual: response }); + * return { response }; + * } + * ); + * }); + * ``` + */ + wrapEvaluator, + type LangSmithJestlikeWrapperParams, + wrapJest, +}; + +export * from "../utils/jestlike/types.js"; diff --git a/src/lib/jest/reporter.ts b/src/lib/jest/reporter.ts new file mode 100644 index 0000000..fd2aaeb --- /dev/null +++ b/src/lib/jest/reporter.ts @@ -0,0 +1,42 @@ +import { DefaultReporter } from "@jest/reporters"; + +import { printReporterTable } from "../utils/jestlike/reporter.js"; + +class LangSmithEvalReporter extends DefaultReporter { + async onTestResult(test: any, testResult: any, aggregatedResults: any) { + if (testResult.failureMessage) { + console.log(testResult.failureMessage); + } + const groupedTestResults = testResult.testResults.reduce( + (groups: Record, testResult: any) => { + const ancestorTitle = testResult.ancestorTitles.join(" > "); + if (groups[ancestorTitle] === undefined) { + groups[ancestorTitle] = []; + } + groups[ancestorTitle].push(testResult); + return groups; + }, + {}, + ); + try { + for (const testGroupName of Object.keys(groupedTestResults)) { + const resultGroup = groupedTestResults[testGroupName]; + const unskippedTests = resultGroup.filter( + (result: any) => result.status !== "pending", + ); + const overallResult = + unskippedTests.length === 0 + ? "skip" + : unskippedTests.every((result: any) => result.status === "passed") + ? "pass" + : "fail"; + await printReporterTable(testGroupName, resultGroup, overallResult); + } + } catch (e: any) { + console.log("Failed to display LangSmith eval results:", e.message); + super.onTestResult(test, testResult, aggregatedResults); + } + } +} + +export default LangSmithEvalReporter; diff --git a/src/lib/langchain.ts b/src/lib/langchain.ts new file mode 100644 index 0000000..f89d709 --- /dev/null +++ b/src/lib/langchain.ts @@ -0,0 +1,173 @@ +// These `@langchain/core` imports are intentionally not peer dependencies +// to avoid package manager issues around circular dependencies. +import { CallbackManager } from "@langchain/core/callbacks/manager"; +import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain"; +import { + Runnable, + RunnableConfig, + patchConfig, + getCallbackManagerForConfig, +} from "@langchain/core/runnables"; + +import { RunTree } from "./run_trees.js"; +import { Run } from "./schemas.js"; +import { + TraceableFunction, + getCurrentRunTree, + isTraceableFunction, +} from "./traceable.js"; +import { isAsyncIterable, isIteratorLike } from "./utils/asserts.js"; + +/** + * Converts the current run tree active within a traceable-wrapped function + * into a LangChain compatible callback manager. This is useful to handoff tracing + * from LangSmith to LangChain Runnables and LLMs. + * + * @param {RunTree | undefined} currentRunTree Current RunTree from within a traceable-wrapped function. If not provided, the current run tree will be inferred from AsyncLocalStorage. + * @returns {CallbackManager | undefined} Callback manager used by LangChain Runnable objects. + */ +export async function getLangchainCallbacks( + currentRunTree?: RunTree | undefined, +) { + const runTree: RunTree | undefined = currentRunTree ?? getCurrentRunTree(); + if (!runTree) return undefined; + + // TODO: CallbackManager.configure() is only async due to LangChainTracer + // factory being unnecessarily async. + let callbacks = await CallbackManager.configure(); + if (!callbacks && runTree.tracingEnabled) { + callbacks = new CallbackManager(); + } + + let langChainTracer = callbacks?.handlers.find( + (handler): handler is LangChainTracer => + handler?.name === "langchain_tracer", + ); + + if (!langChainTracer && runTree.tracingEnabled) { + langChainTracer = new LangChainTracer(); + callbacks?.addHandler(langChainTracer); + } + + const runMap = new Map(); + + // find upward root run + let rootRun = runTree; + const rootVisited = new Set(); + while (rootRun.parent_run) { + if (rootVisited.has(rootRun.id)) break; + rootVisited.add(rootRun.id); + rootRun = rootRun.parent_run; + } + + const queue = [rootRun]; + const visited = new Set(); + + while (queue.length > 0) { + const current = queue.shift(); + if (!current || visited.has(current.id)) continue; + visited.add(current.id); + + runMap.set(current.id, current); + if (current.child_runs) { + queue.push(...current.child_runs); + } + } + + if (callbacks != null) { + Object.assign(callbacks, { _parentRunId: runTree.id }); + } + + if (langChainTracer != null) { + if ( + "updateFromRunTree" in langChainTracer && + typeof langChainTracer === "function" + ) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore @langchain/core can use a different version of LangSmith + langChainTracer.updateFromRunTree(runTree); + } else { + Object.assign(langChainTracer, { + runMap, + client: runTree.client, + projectName: runTree.project_name || langChainTracer.projectName, + exampleId: runTree.reference_example_id || langChainTracer.exampleId, + }); + } + } + + return callbacks; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyTraceableFunction = TraceableFunction<(...any: any[]) => any>; + +/** + * RunnableTraceable is a Runnable that wraps a traceable function. + * This allows adding Langsmith traced functions into LangChain sequences. + * @deprecated Wrap or pass directly instead. + */ +export class RunnableTraceable extends Runnable< + RunInput, + RunOutput +> { + lc_serializable = false; + + lc_namespace = ["langchain_core", "runnables"]; + + protected func: AnyTraceableFunction; + + constructor(fields: { func: AnyTraceableFunction }) { + super(fields); + + if (!isTraceableFunction(fields.func)) { + throw new Error( + "RunnableTraceable requires a function that is wrapped in traceable higher-order function", + ); + } + + this.func = fields.func; + } + + async invoke(input: RunInput, options?: Partial) { + const [config] = this._getOptionsList(options ?? {}, 1); + const callbacks = await getCallbackManagerForConfig(config); + // Avoid start time ties - this is old, deprecated code used only in tests + // and recent perf improvements have made this necessary. + await new Promise((resolve) => setImmediate(resolve)); + + return (await this.func( + patchConfig(config, { callbacks }), + input, + )) as RunOutput; + } + + async *_streamIterator( + input: RunInput, + options?: Partial, + ): AsyncGenerator { + const result = await this.invoke(input, options); + + if (isAsyncIterable(result)) { + for await (const item of result) { + yield item as RunOutput; + } + return; + } + + if (isIteratorLike(result)) { + while (true) { + const state: IteratorResult = result.next(); + if (state.done) break; + yield state.value as RunOutput; + } + return; + } + + yield result; + } + + static from(func: AnyTraceableFunction) { + return new RunnableTraceable({ func }); + } +} diff --git a/src/lib/run_trees.ts b/src/lib/run_trees.ts new file mode 100644 index 0000000..93ce517 --- /dev/null +++ b/src/lib/run_trees.ts @@ -0,0 +1,1308 @@ +import { Client } from "./client.js"; +import { isEnvTracingEnabled } from "./env.js"; +import { + Attachments, + BaseRun, + KVMap, + RunCreate, + RunUpdate, +} from "./schemas.js"; +import { + isConflictingEndpointsError, + ConflictingEndpointsError, +} from "./utils/error.js"; +import { + _LC_CONTEXT_VARIABLES_KEY, + _REPLICA_TRACE_ROOTS_KEY, +} from "./singletons/constants.js"; +import { getContextVar, setContextVar } from "./utils/context_vars.js"; +import { + RuntimeEnvironment, + getEnvironmentVariable, + getRuntimeEnvironment, +} from "./utils/env.js"; +import { getDefaultProjectName } from "./utils/project.js"; +import { getLangSmithEnvironmentVariable } from "./utils/env.js"; +import { warnOnce } from "./utils/warn.js"; +import { + uuid7FromTime, + nonCryptographicUuid7Deterministic, +} from "./utils/_uuid.js"; +import { v5 as uuidv5 } from "./utils/uuid/src/index.js"; + +const TIMESTAMP_LENGTH = 36; +// DNS namespace for UUID v5 (same as Python's uuid.NAMESPACE_DNS) +const UUID_NAMESPACE_DNS = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + +function getReplicaKey(replica: { + projectName?: string; + apiUrl?: string; + workspaceId?: string; + apiKey?: string; +}): string { + // Generate a unique key by hashing the replica's identifying properties + // This ensures each unique replica (combination of projectName, apiUrl, workspaceId, apiKey) gets a unique key + // Sort keys to ensure consistent hashing + const sortedKeys = Object.keys(replica).sort(); + const keyData = sortedKeys + .map((key) => `${key}:${replica[key as keyof typeof replica] ?? ""}`) + .join("|"); + return uuidv5(keyData, UUID_NAMESPACE_DNS); +} + +function stripNonAlphanumeric(input: string) { + return input.replace(/[-:.]/g, ""); +} + +function getMicrosecondPrecisionDatestring( + epoch: number, + executionOrder = 1, +): string { + // Date only has millisecond precision, so we use the microseconds to break + // possible ties, avoiding incorrect run order + const paddedOrder = executionOrder.toFixed(0).slice(0, 3).padStart(3, "0"); + return `${new Date(epoch).toISOString().slice(0, -1)}${paddedOrder}Z`; +} + +export function convertToDottedOrderFormat( + epoch: number, + runId: string, + executionOrder = 1, +) { + const microsecondPrecisionDatestring = getMicrosecondPrecisionDatestring( + epoch, + executionOrder, + ); + return { + dottedOrder: stripNonAlphanumeric(microsecondPrecisionDatestring) + runId, + microsecondPrecisionDatestring, + }; +} + +export interface RunTreeConfig { + name: string; + run_type?: string; + id?: string; + project_name?: string; + parent_run?: RunTree; + parent_run_id?: string; + child_runs?: RunTree[]; + start_time?: number | string; + end_time?: number | string; + extra?: KVMap; + metadata?: KVMap; + tags?: string[]; + error?: string; + serialized?: object; + inputs?: KVMap; + outputs?: KVMap; + reference_example_id?: string; + client?: Client; + tracingEnabled?: boolean; + // TODO: Change/alias as `onEnd`. + on_end?: (runTree: RunTree) => void; + execution_order?: number; + child_execution_order?: number; + + trace_id?: string; + dotted_order?: string; + attachments?: Attachments; + replicas?: Replica[]; + distributedParentId?: string; +} + +// TODO: Remove in 0.4 +export interface RunnableConfigLike { + /** + * Tags for this call and any sub-calls (eg. a Chain calling an LLM). + * You can use these to filter calls. + */ + tags?: string[]; + + /** + * Metadata for this call and any sub-calls (eg. a Chain calling an LLM). + * Keys should be strings, values should be JSON-serializable. + */ + metadata?: Record; + + /** + * Callbacks for this call and any sub-calls (eg. a Chain calling an LLM). + * Tags are passed to all callbacks, metadata is passed to handle*Start callbacks. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callbacks?: Record | any[]; +} + +interface CallbackManagerLike { + handlers: TracerLike[]; + getParentRunId?: () => string | undefined; + copy?: () => CallbackManagerLike; +} + +interface TracerLike { + name: string; +} + +interface LangChainTracerLike extends TracerLike { + name: "langchain_tracer"; + projectName: string; + getRun?: (id: string) => RunTree | undefined; + client: Client; + updateFromRunTree?: (runTree: RunTree) => void; +} + +interface HeadersLike { + get(name: string): string | null; + set(name: string, value: string): void; +} + +type ProjectReplica = [string, KVMap | undefined]; +export type WriteReplica = { + apiUrl?: string; + apiKey?: string; + workspaceId?: string; + projectName?: string; + /** Whether this replica keeps the original run IDs. */ + primary?: boolean; + updates?: KVMap | undefined; + fromEnv?: boolean; + reroot?: boolean; + /** + * Optional dedicated Client for this replica. + * When set, the replica's runs are dispatched through this client + * (and its tracing mode) instead of the RunTree's default client. + * Not serialized in distributed-tracing baggage headers. + */ + client?: Client; +}; +type Replica = ProjectReplica | WriteReplica; + +const HEADER_SAFE_REPLICA_FIELDS = new Set([ + "projectName", + "primary", + "updates", + "reroot", +]); + +function filterReplicaForHeaders(replica: WriteReplica): WriteReplica { + const filtered: WriteReplica = {}; + for (const key of Object.keys(replica) as (keyof WriteReplica)[]) { + if (key === "primary" && typeof replica[key] !== "boolean") { + continue; + } + if (HEADER_SAFE_REPLICA_FIELDS.has(key)) { + (filtered as Record)[key] = replica[key]; + } + } + return filtered; +} + +/** + * Baggage header information + */ +class Baggage { + metadata: KVMap | undefined; + tags: string[] | undefined; + project_name: string | undefined; + replicas: Replica[] | undefined; + constructor( + metadata: KVMap | undefined, + tags: string[] | undefined, + project_name: string | undefined, + replicas: Replica[] | undefined, + ) { + this.metadata = metadata; + this.tags = tags; + this.project_name = project_name; + this.replicas = replicas; + } + + static fromHeader(value: string) { + const items = value.split(","); + let metadata: KVMap = {}; + let tags: string[] = []; + let project_name: string | undefined; + let replicas: Replica[] | undefined; + for (const item of items) { + const [key, uriValue] = item.split("="); + const value = decodeURIComponent(uriValue); + if (key === "langsmith-metadata") { + metadata = JSON.parse(value); + } else if (key === "langsmith-tags") { + tags = value.split(","); + } else if (key === "langsmith-project") { + project_name = value; + } else if (key === "langsmith-replicas") { + const parsed = JSON.parse(value) as Replica[]; + replicas = parsed.map((replica) => { + if (Array.isArray(replica)) { + return replica; + } + return filterReplicaForHeaders(replica); + }); + } + } + + return new Baggage(metadata, tags, project_name, replicas); + } + + toHeader(): string { + const items = []; + if (this.metadata && Object.keys(this.metadata).length > 0) { + items.push( + `langsmith-metadata=${encodeURIComponent( + JSON.stringify(this.metadata), + )}`, + ); + } + if (this.tags && this.tags.length > 0) { + items.push(`langsmith-tags=${encodeURIComponent(this.tags.join(","))}`); + } + if (this.project_name) { + items.push(`langsmith-project=${encodeURIComponent(this.project_name)}`); + } + + return items.join(","); + } +} + +function getExcludeInputsOnPatch(): boolean { + const value = getLangSmithEnvironmentVariable("EXCLUDE_INPUTS_ON_PATCH"); + return value === undefined || value.toLowerCase() === "true" || value === "1"; +} + +export class RunTree implements BaseRun { + private static sharedClient: Client | null = null; + + id: string; + name: RunTreeConfig["name"]; + run_type: string; + project_name: string; + parent_run?: RunTree; + parent_run_id?: string; + child_runs: RunTree[]; + start_time: number; + end_time?: number; + extra: KVMap; + tags?: string[]; + error?: string; + serialized: object; + inputs: KVMap; + outputs?: KVMap; + reference_example_id?: string; + client: Client; + events?: KVMap[] | undefined; + trace_id: string; + dotted_order: string; + + tracingEnabled?: boolean; + execution_order: number; + child_execution_order: number; + /** + * Attachments associated with the run. + * Each entry is a tuple of [mime_type, bytes] + */ + attachments?: Attachments; + /** + * Projects to replicate this run to with optional updates. + */ + replicas?: WriteReplica[]; + + distributedParentId?: string; + + /** + * @interface + */ + private _serialized_start_time: string | undefined; + + /** + * @internal + */ + _awaitInputsOnPost?: boolean; + + constructor(originalConfig: RunTreeConfig | RunTree) { + // If you pass in a run tree directly, return a shallow clone + if (isRunTree(originalConfig)) { + Object.assign(this, { ...originalConfig }); + return; + } + const defaultConfig = RunTree.getDefaultConfig(); + const { metadata, ...config } = originalConfig; + const client = config.client ?? RunTree.getSharedClient(); + const dedupedMetadata = { + ...metadata, + ...config?.extra?.metadata, + }; + config.extra = { ...config.extra, metadata: dedupedMetadata }; + if ("id" in config && config.id == null) { + delete config.id; + } + Object.assign(this, { ...defaultConfig, ...config, client }); + + this.execution_order ??= 1; + this.child_execution_order ??= 1; + + // Generate serialized start time for ID generation + if (!this.dotted_order) { + this._serialized_start_time = getMicrosecondPrecisionDatestring( + this.start_time, + this.execution_order, + ); + } + + // Generate id from serialized start_time if not provided + if (!this.id) { + this.id = uuid7FromTime(this._serialized_start_time ?? this.start_time); + } + + if (!this.trace_id) { + if (this.parent_run) { + this.trace_id = this.parent_run.trace_id ?? this.id; + } else { + this.trace_id = this.id; + } + } + + this.replicas = _ensureWriteReplicas(this.replicas); + + // Now set the dotted order with the actual ID + if (!this.dotted_order) { + const { dottedOrder } = convertToDottedOrderFormat( + this.start_time, + this.id, + this.execution_order, + ); + if (this.parent_run) { + this.dotted_order = this.parent_run.dotted_order + "." + dottedOrder; + } else { + this.dotted_order = dottedOrder; + } + } + } + + set metadata(metadata: KVMap) { + this.extra = { + ...this.extra, + metadata: { + ...this.extra?.metadata, + ...metadata, + }, + }; + } + + get metadata() { + return this.extra?.metadata; + } + + private static getDefaultConfig(): object { + const start_time = Date.now(); + return { + run_type: "chain", + project_name: getDefaultProjectName(), + child_runs: [], + api_url: + getEnvironmentVariable("LANGCHAIN_ENDPOINT") ?? "http://localhost:1984", + api_key: getEnvironmentVariable("LANGCHAIN_API_KEY"), + caller_options: {}, + start_time, + serialized: {}, + inputs: {}, + extra: {}, + }; + } + + static getSharedClient(): Client { + if (!RunTree.sharedClient) { + RunTree.sharedClient = new Client(); + } + return RunTree.sharedClient; + } + + public createChild(config: RunTreeConfig): RunTree { + const child_execution_order = this.child_execution_order + 1; + + // Handle replicas: if child has its own replicas, use those; otherwise inherit parent's (with reroot stripped) + // Reroot should only apply to the run where it's explicitly configured, not propagate down + const inheritedReplicas = this.replicas?.map((replica) => { + const { reroot, ...rest } = replica; + return rest; + }); + + const childReplicas = config.replicas ?? inheritedReplicas; + + const child = new RunTree({ + ...config, + parent_run: this, + project_name: this.project_name, + replicas: childReplicas, + client: this.client, + tracingEnabled: this.tracingEnabled, + execution_order: child_execution_order, + child_execution_order: child_execution_order, + }); + + // Propagate all parent metadata; child metadata takes precedence. + const parentMeta = this.extra?.metadata ?? {}; + const childMeta = child.extra?.metadata ?? {}; + if (Object.keys(parentMeta).length > 0) { + child.extra = { + ...child.extra, + metadata: { ...parentMeta, ...childMeta }, + }; + } + + // Copy context vars over into the new run tree. + if (_LC_CONTEXT_VARIABLES_KEY in this) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (child as any)[_LC_CONTEXT_VARIABLES_KEY] = + this[_LC_CONTEXT_VARIABLES_KEY]; + } + + type ExtraWithSymbol = Record; + const LC_CHILD = Symbol.for("lc:child_config"); + + const presentConfig = + (config.extra as ExtraWithSymbol | undefined)?.[LC_CHILD] ?? + (this.extra as ExtraWithSymbol)[LC_CHILD]; + + // tracing for LangChain is defined by the _parentRunId and runMap of the tracer + if (isRunnableConfigLike(presentConfig)) { + const newConfig: RunnableConfigLike = { ...presentConfig }; + const callbacks: CallbackManagerLike | unknown[] | undefined = + isCallbackManagerLike(newConfig.callbacks) + ? newConfig.callbacks.copy?.() + : undefined; + + if (callbacks) { + // update the parent run id + Object.assign(callbacks, { _parentRunId: child.id }); + + // only populate if we're in a newer LC.JS version + callbacks.handlers + ?.find(isLangChainTracerLike) + ?.updateFromRunTree?.(child); + + newConfig.callbacks = callbacks; + } + + (child.extra as ExtraWithSymbol)[LC_CHILD] = newConfig; + } + + // propagate child_execution_order upwards + const visited = new Set(); + let current: RunTree | undefined = this as RunTree; + while (current != null && !visited.has(current.id)) { + visited.add(current.id); + current.child_execution_order = Math.max( + current.child_execution_order, + child_execution_order, + ); + + current = current.parent_run; + } + + this.child_runs.push(child); + return child; + } + + async end( + outputs?: KVMap, + error?: string, + endTime = Date.now(), + metadata?: KVMap, + ): Promise { + this.outputs = this.outputs ?? outputs; + this.error = this.error ?? error; + this.end_time = this.end_time ?? endTime; + if (metadata && Object.keys(metadata).length > 0) { + this.extra = this.extra + ? { ...this.extra, metadata: { ...this.extra.metadata, ...metadata } } + : { metadata }; + } + } + + private _convertToCreate( + run: RunTree, + runtimeEnv: RuntimeEnvironment | undefined, + excludeChildRuns = true, + ): RunCreate & { id: string } { + const runExtra = run.extra ?? {}; + // Avoid overwriting the runtime environment if it's already set + if (runExtra?.runtime?.library === undefined) { + if (!runExtra.runtime) { + runExtra.runtime = {}; + } + if (runtimeEnv) { + for (const [k, v] of Object.entries(runtimeEnv)) { + if (!runExtra.runtime[k]) { + runExtra.runtime[k] = v; + } + } + } + } + + const parent_run_id = run.parent_run?.id ?? run.parent_run_id; + let child_runs: (RunCreate & { id: string })[]; + if (!excludeChildRuns) { + child_runs = run.child_runs.map((child_run) => + this._convertToCreate(child_run, runtimeEnv, excludeChildRuns), + ); + } else { + child_runs = []; + } + return { + id: run.id, + name: run.name, + start_time: run._serialized_start_time ?? run.start_time, + end_time: run.end_time, + run_type: run.run_type, + reference_example_id: run.reference_example_id, + extra: runExtra, + serialized: run.serialized, + error: run.error, + inputs: run.inputs, + outputs: run.outputs, + session_name: run.project_name, + child_runs: child_runs, + parent_run_id: parent_run_id, + trace_id: run.trace_id, + dotted_order: run.dotted_order, + tags: run.tags, + attachments: run.attachments, + events: run.events, + }; + } + + private _sliceParentId( + parentId: string, + run: RunCreate & { id: string }, + ): void { + /** + * Slice the parent id from dotted order. + * Additionally check if the current run is a child of the parent. If so, update + * the parent_run_id to undefined, and set the trace id to the new root id after + * parent_id. + */ + if (run.dotted_order) { + const segs = run.dotted_order.split("."); + let startIdx: number | null = null; + + // Find the index of the parent ID in the dotted order + for (let idx = 0; idx < segs.length; idx++) { + const segId = segs[idx].slice(-TIMESTAMP_LENGTH); + if (segId === parentId) { + startIdx = idx; + break; + } + } + + if (startIdx !== null) { + // Trim segments to start after parent_id (exclusive) + const trimmedSegs = segs.slice(startIdx + 1); + // Rebuild dotted_order + run.dotted_order = trimmedSegs.join("."); + if (trimmedSegs.length > 0) { + run.trace_id = trimmedSegs[0].slice(-TIMESTAMP_LENGTH); + } else { + run.trace_id = run.id; + } + } + } + + if (run.parent_run_id === parentId) { + // We've found the new root node. + run.parent_run_id = undefined; + } + } + + private _setReplicaTraceRoot(replicaKey: string, traceRootId: string): void { + // Set the replica trace root in context vars on this run and all descendants + const replicaTraceRoots: Record = + (getContextVar(this, _REPLICA_TRACE_ROOTS_KEY) as Record< + string, + string + >) ?? {}; + replicaTraceRoots[replicaKey] = traceRootId; + + setContextVar(this, _REPLICA_TRACE_ROOTS_KEY, replicaTraceRoots); + + // Recursively update all descendants to avoid race conditions + // around run tree creation vs processing time + for (const child of this.child_runs) { + child._setReplicaTraceRoot(replicaKey, traceRootId); + } + } + + private _remapForProject(params: { + projectName: string; + primary?: boolean; + runtimeEnv?: RuntimeEnvironment; + excludeChildRuns?: boolean; + reroot?: boolean; + distributedParentId?: string; + apiUrl?: string; + apiKey?: string; + workspaceId?: string; + }): RunCreate & { id: string } { + const { + projectName, + primary, + runtimeEnv, + excludeChildRuns = true, + reroot = false, + distributedParentId, + apiUrl, + apiKey, + workspaceId, + } = params; + const baseRun = this._convertToCreate(this, runtimeEnv, excludeChildRuns); + + // Preserve legacy behavior when `primary` is omitted, unless another replica is primary. + if ( + primary === undefined && + !this.replicas?.some((r) => r.primary === true) && + projectName === this.project_name + ) { + return { + ...baseRun, + session_name: projectName, + }; + } + + // Apply reroot logic before ID remapping + if (reroot) { + if (distributedParentId) { + // If we have a distributed parent ID, slice at that point + this._sliceParentId(distributedParentId, baseRun); + } else { + // If no distributed parent ID, simply make this run a root run + // by removing parent_run_id and resetting trace info + baseRun.parent_run_id = undefined; + // Keep the current run as the trace root + if (baseRun.dotted_order) { + // Reset dotted order to just this run + const segs = baseRun.dotted_order.split("."); + if (segs.length > 0) { + baseRun.dotted_order = segs[segs.length - 1]; + baseRun.trace_id = baseRun.id; + } + } + } + + // Store this run's original ID in context vars so descendants know the new trace root + // We store the original ID (before remapping) so it can be found in dotted_order + const replicaKey = getReplicaKey({ + projectName, + apiUrl, + apiKey, + workspaceId, + }); + this._setReplicaTraceRoot(replicaKey, baseRun.id); + } + + // If an ancestor was rerooted for this replica, update trace_id and dotted_order + // to reflect the new trace hierarchy. This is tracked via context variables. + let ancestorRerootedTraceId: string | undefined; + if (!reroot) { + const replicaTraceRoots: Record = + (getContextVar(this, _REPLICA_TRACE_ROOTS_KEY) as Record< + string, + string + >) ?? {}; + const replicaKey = getReplicaKey({ + projectName, + apiUrl, + apiKey, + workspaceId, + }); + ancestorRerootedTraceId = replicaTraceRoots[replicaKey]; + + if (ancestorRerootedTraceId) { + // An ancestor was rerooted for this replica, so set our trace_id + // to the ancestor's original (unmapped) ID. It will be remapped along with other IDs. + baseRun.trace_id = ancestorRerootedTraceId; + + // Also slice the dotted_order to start from the new trace root + // This ensures descendants of a rerooted ancestor have correct hierarchy + if (baseRun.dotted_order) { + const segs = baseRun.dotted_order.split("."); + let rootIdx: number | null = null; + + // Find the new trace root's segment in dotted_order + for (let idx = 0; idx < segs.length; idx++) { + const segId = segs[idx].slice(-TIMESTAMP_LENGTH); + if (segId === ancestorRerootedTraceId) { + rootIdx = idx; + break; + } + } + + if (rootIdx !== null) { + // Keep segments from new trace root onwards + const trimmedSegs = segs.slice(rootIdx); + baseRun.dotted_order = trimmedSegs.join("."); + } + } + } + } + + if (primary) { + return { + ...baseRun, + session_name: projectName, + }; + } + + // Remap IDs for the replica using nonCryptographicUuid7Deterministic + // This ensures consistency across runs in the same replica while + // preserving UUID7 properties (time-ordering, monotonicity) + const oldId = baseRun.id; + const newId = nonCryptographicUuid7Deterministic(oldId, projectName); + + // Remap trace_id + let newTraceId: string; + if (baseRun.trace_id) { + newTraceId = nonCryptographicUuid7Deterministic( + baseRun.trace_id, + projectName, + ); + } else { + newTraceId = newId; + } + + // Remap parent_run_id + let newParentId: string | undefined; + if (baseRun.parent_run_id) { + newParentId = nonCryptographicUuid7Deterministic( + baseRun.parent_run_id, + projectName, + ); + } + + // Remap dotted_order segments + let newDottedOrder: string | undefined; + if (baseRun.dotted_order) { + const segs = baseRun.dotted_order.split("."); + const remappedSegs = segs.map((seg) => { + // Extract the UUID from the segment (last TIMESTAMP_LENGTH characters) + const segId = seg.slice(-TIMESTAMP_LENGTH); + const remappedId = nonCryptographicUuid7Deterministic( + segId, + projectName, + ); + // Replace the UUID part while keeping the timestamp prefix + return seg.slice(0, -TIMESTAMP_LENGTH) + remappedId; + }); + newDottedOrder = remappedSegs.join("."); + } + + return { + ...baseRun, + id: newId, + trace_id: newTraceId, + parent_run_id: newParentId, + dotted_order: newDottedOrder, + session_name: projectName, + }; + } + + async postRun(excludeChildRuns = true): Promise { + // Applies when `processInputs` is an async function + if (this._awaitInputsOnPost) { + this.inputs = await (this.inputs as Promise); + } + try { + const runtimeEnv = getRuntimeEnvironment(); + if (this.replicas && this.replicas.length > 0) { + for (const { + projectName, + primary, + apiKey, + apiUrl, + workspaceId, + reroot, + client: replicaClient, + } of this.replicas) { + const runCreate = this._remapForProject({ + projectName: projectName ?? this.project_name, + primary, + runtimeEnv, + excludeChildRuns: true, + reroot, + distributedParentId: this.distributedParentId, + apiUrl, + apiKey, + workspaceId, + }); + const targetClient = replicaClient ?? this.client; + await targetClient.createRun(runCreate, { + apiKey, + apiUrl, + workspaceId, + }); + } + } else { + const runCreate = this._convertToCreate( + this, + runtimeEnv, + excludeChildRuns, + ); + await this.client.createRun(runCreate); + } + + if (!excludeChildRuns) { + warnOnce( + "Posting with excludeChildRuns=false is deprecated and will be removed in a future version.", + ); + for (const childRun of this.child_runs) { + await childRun.postRun(false); + } + } + + this.child_runs = []; + } catch (error) { + console.error(`Error in postRun for run ${this.id}:`, error); + } + } + + /** + * Patch the run tree to the API. + * + * @param options.excludeInputs - Whether to exclude inputs from the patch + * request. Defaults to the value of `LANGSMITH_EXCLUDE_INPUTS_ON_PATCH` + * (or its `LANGCHAIN_` equivalent), which itself defaults to `true`. + * An explicit value overrides the environment variable for this call. + * + * Unless the environment variable is disabled, inputs added after the + * initial `postRun()` are not persisted unless this option is explicitly set + * to `false`. + */ + async patchRun(options?: { excludeInputs?: boolean }): Promise { + const excludeInputs = options?.excludeInputs ?? getExcludeInputsOnPatch(); + if (this.replicas && this.replicas.length > 0) { + for (const { + projectName, + primary, + apiKey, + apiUrl, + workspaceId, + updates, + reroot, + client: replicaClient, + } of this.replicas) { + const runData = this._remapForProject({ + projectName: projectName ?? this.project_name, + primary, + runtimeEnv: undefined, + excludeChildRuns: true, + reroot, + distributedParentId: this.distributedParentId, + apiUrl, + apiKey, + workspaceId, + }); + const updatePayload: RunUpdate = { + id: runData.id, + name: runData.name, + run_type: runData.run_type, + start_time: runData.start_time, + outputs: runData.outputs, + error: runData.error, + parent_run_id: runData.parent_run_id, + session_name: runData.session_name, + reference_example_id: runData.reference_example_id, + end_time: runData.end_time, + dotted_order: runData.dotted_order, + trace_id: runData.trace_id, + events: runData.events, + tags: runData.tags, + extra: runData.extra, + attachments: this.attachments, + ...updates, + }; + // Important that inputs is not a key in the run update + // if excluded because it will overwrite the run create if the + // two operations are merged during batching + if (!excludeInputs) { + updatePayload.inputs = runData.inputs; + } + const targetClient = replicaClient ?? this.client; + await targetClient.updateRun(runData.id, updatePayload, { + apiKey, + apiUrl, + workspaceId, + }); + } + } else { + try { + const runUpdate: RunUpdate = { + name: this.name, + run_type: this.run_type, + start_time: this._serialized_start_time ?? this.start_time, + end_time: this.end_time, + error: this.error, + outputs: this.outputs, + parent_run_id: this.parent_run?.id ?? this.parent_run_id, + reference_example_id: this.reference_example_id, + extra: this.extra, + events: this.events, + dotted_order: this.dotted_order, + trace_id: this.trace_id, + tags: this.tags, + attachments: this.attachments, + session_name: this.project_name, + }; + // Important that inputs is not a key in the run update + // if excluded because it will overwrite the run create if the + // two operations are merged during batching + if (!excludeInputs) { + runUpdate.inputs = this.inputs; + } + await this.client.updateRun(this.id, runUpdate); + } catch (error) { + console.error(`Error in patchRun for run ${this.id}`, error); + } + } + + this.child_runs = []; + } + + toJSON() { + return this._convertToCreate(this, undefined, false); + } + + /** + * Add an event to the run tree. + * @param event - A single event or string to add + */ + addEvent(event: RunEvent | string): void { + if (!this.events) { + this.events = []; + } + + if (typeof event === "string") { + this.events.push({ + name: "event", + time: new Date().toISOString(), + message: event, + }); + } else { + this.events.push({ + ...event, + time: event.time ?? new Date().toISOString(), + }); + } + } + + static fromRunnableConfig( + parentConfig: RunnableConfigLike, + props: RunTreeConfig, + ): RunTree { + // We only handle the callback manager case for now + const callbackManager = parentConfig?.callbacks as + | CallbackManagerLike + | undefined; + let parentRun: RunTree | undefined; + let projectName: string | undefined; + let client: Client | undefined; + + let tracingEnabled = isEnvTracingEnabled(); + + if (callbackManager) { + const parentRunId = callbackManager?.getParentRunId?.() ?? ""; + const langChainTracer = callbackManager?.handlers?.find( + (handler: TracerLike) => handler?.name == "langchain_tracer", + ) as LangChainTracerLike | undefined; + + parentRun = langChainTracer?.getRun?.(parentRunId); + projectName = langChainTracer?.projectName; + client = langChainTracer?.client; + tracingEnabled = tracingEnabled || !!langChainTracer; + } + + if (!parentRun) { + return new RunTree({ + ...props, + client, + tracingEnabled, + project_name: projectName, + }); + } + + const parentRunTree = new RunTree({ + name: parentRun.name, + id: parentRun.id, + trace_id: parentRun.trace_id, + dotted_order: parentRun.dotted_order, + client, + tracingEnabled, + project_name: projectName, + tags: [ + ...new Set((parentRun?.tags ?? []).concat(parentConfig?.tags ?? [])), + ], + extra: { + metadata: { + ...parentRun?.extra?.metadata, + ...parentConfig?.metadata, + }, + }, + }); + + return parentRunTree.createChild(props); + } + + static fromDottedOrder(dottedOrder: string): RunTree | undefined { + return this.fromHeaders({ "langsmith-trace": dottedOrder }); + } + + static fromHeaders( + headers: Record | HeadersLike, + inheritArgs?: Partial, + ): RunTree | undefined { + const rawHeaders: Record = + "get" in headers && typeof headers.get === "function" + ? { + "langsmith-trace": headers.get("langsmith-trace"), + baggage: headers.get("baggage"), + } + : (headers as Record); + + const headerTrace = rawHeaders["langsmith-trace"]; + if (!headerTrace || typeof headerTrace !== "string") return undefined; + + const parentDottedOrder = headerTrace.trim(); + const parsedDottedOrder = parentDottedOrder.split(".").map((part) => { + const [strTime, uuid] = part.split("Z"); + return { strTime, time: Date.parse(strTime + "Z"), uuid }; + }); + + const traceId = parsedDottedOrder[0].uuid; + + const config: RunTreeConfig = { + ...inheritArgs, + name: inheritArgs?.["name"] ?? "parent", + run_type: inheritArgs?.["run_type"] ?? "chain", + start_time: inheritArgs?.["start_time"] ?? Date.now(), + id: parsedDottedOrder.at(-1)?.uuid, + trace_id: traceId, + dotted_order: parentDottedOrder, + }; + + if (rawHeaders["baggage"] && typeof rawHeaders["baggage"] === "string") { + const baggage = Baggage.fromHeader(rawHeaders["baggage"]); + config.metadata = baggage.metadata; + config.tags = baggage.tags; + config.project_name = baggage.project_name; + config.replicas = baggage.replicas; + } + + const runTree = new RunTree(config); + // Set the distributed parent ID to this run's ID for rerooting + runTree.distributedParentId = runTree.id; + return runTree; + } + + toHeaders(headers?: HeadersLike) { + const result = { + "langsmith-trace": this.dotted_order, + baggage: new Baggage( + this.extra?.metadata, + this.tags, + this.project_name, + this.replicas, + ).toHeader(), + }; + + if (headers) { + for (const [key, value] of Object.entries(result)) { + headers.set(key, value); + } + } + + return result; + } +} + +export function isRunTree(x?: unknown): x is RunTree { + return ( + x != null && + typeof (x as RunTree).createChild === "function" && + typeof (x as RunTree).postRun === "function" + ); +} + +function isLangChainTracerLike(x: unknown): x is LangChainTracerLike { + return ( + typeof x === "object" && + x != null && + typeof (x as LangChainTracerLike).name === "string" && + (x as LangChainTracerLike).name === "langchain_tracer" + ); +} + +function containsLangChainTracerLike(x: unknown): x is LangChainTracerLike[] { + return ( + Array.isArray(x) && x.some((callback) => isLangChainTracerLike(callback)) + ); +} + +function isCallbackManagerLike(x: unknown): x is CallbackManagerLike { + return ( + typeof x === "object" && + x != null && + Array.isArray((x as CallbackManagerLike).handlers) + ); +} + +export interface RunEvent { + name?: string; + time?: string; + message?: string; + kwargs?: Record; + [key: string]: unknown; +} + +export function isRunnableConfigLike(x?: unknown): x is RunnableConfigLike { + // Check that it's an object with a callbacks arg + // that has either a CallbackManagerLike object with a langchain tracer within it + // or an array with a LangChainTracerLike object within it + + const callbacks = (x as RunnableConfigLike)?.callbacks; + return ( + x != null && + typeof callbacks === "object" && + // Callback manager with a langchain tracer + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (containsLangChainTracerLike((callbacks as any)?.handlers) || + // Or it's an array with a LangChainTracerLike object within it + containsLangChainTracerLike(callbacks)) + ); +} + +function _getWriteReplicasFromEnv(): WriteReplica[] { + const envVar = getEnvironmentVariable("LANGSMITH_RUNS_ENDPOINTS"); + if (!envVar) return []; + try { + const parsed = JSON.parse(envVar); + + if (Array.isArray(parsed)) { + const replicas: WriteReplica[] = []; + for (const item of parsed) { + if (typeof item !== "object" || item === null) { + console.warn( + `Invalid item type in LANGSMITH_RUNS_ENDPOINTS: ` + + `expected object, got ${typeof item}`, + ); + continue; + } + + if (typeof item.api_url !== "string") { + console.warn( + `Invalid api_url type in LANGSMITH_RUNS_ENDPOINTS: ` + + `expected string, got ${typeof item.api_url}`, + ); + continue; + } + + if (typeof item.api_key !== "string") { + console.warn( + `Invalid api_key type in LANGSMITH_RUNS_ENDPOINTS: ` + + `expected string, got ${typeof item.api_key}`, + ); + continue; + } + + if ( + item.project_name !== undefined && + item.project_name !== null && + typeof item.project_name !== "string" + ) { + console.warn( + `Invalid project_name type in LANGSMITH_RUNS_ENDPOINTS: ` + + `expected string, got ${typeof item.project_name}`, + ); + continue; + } + + if (item.primary !== undefined && typeof item.primary !== "boolean") { + console.warn( + `Invalid primary type in LANGSMITH_RUNS_ENDPOINTS: ` + + `expected boolean, got ${typeof item.primary}`, + ); + continue; + } + + replicas.push({ + apiUrl: item.api_url.replace(/\/$/, ""), + apiKey: item.api_key, + projectName: item.project_name ?? undefined, + primary: item.primary ?? undefined, + }); + } + return replicas; + } else if (typeof parsed === "object" && parsed !== null) { + _checkEndpointEnvUnset(parsed); + + const replicas: WriteReplica[] = []; + for (const [url, key] of Object.entries(parsed)) { + const cleanUrl = url.replace(/\/$/, ""); + + if (typeof key === "string") { + replicas.push({ + apiUrl: cleanUrl, + apiKey: key, + }); + } else { + console.warn( + `Invalid value type in LANGSMITH_RUNS_ENDPOINTS for URL ${url}: ` + + `expected string, got ${typeof key}`, + ); + continue; + } + } + return replicas; + } else { + console.warn( + "Invalid LANGSMITH_RUNS_ENDPOINTS – must be valid JSON array of " + + `objects with api_url and api_key properties, or object mapping url->apiKey, got ${typeof parsed}`, + ); + return []; + } + } catch (e) { + if (isConflictingEndpointsError(e)) { + throw e; + } + console.warn( + "Invalid LANGSMITH_RUNS_ENDPOINTS – must be valid JSON array of " + + "objects with api_url and api_key properties, or object mapping url->apiKey", + ); + return []; + } +} + +function _ensureWriteReplicas(replicas?: Replica[]): WriteReplica[] { + const ensured = replicas + ? replicas.map((replica) => { + if (Array.isArray(replica)) { + return { + projectName: replica[0], + updates: replica[1], + }; + } + return replica; + }) + : _getWriteReplicasFromEnv(); + if (ensured.filter((replica) => replica.primary === true).length > 1) { + throw new Error("Only one replica can be marked as primary."); + } + return ensured; +} + +function _checkEndpointEnvUnset(parsed: Record) { + if ( + Object.keys(parsed).length > 0 && + getLangSmithEnvironmentVariable("ENDPOINT") + ) { + throw new ConflictingEndpointsError(); + } +} diff --git a/src/lib/sandbox/README.md b/src/lib/sandbox/README.md new file mode 100644 index 0000000..4b66642 --- /dev/null +++ b/src/lib/sandbox/README.md @@ -0,0 +1,985 @@ +# LangSmith Sandbox (JavaScript/TypeScript) + +Sandboxed code execution for LangSmith. Run untrusted code safely in isolated containers. + +## Quick Start + +By default, sandboxes are created with the server's standard runtime. You only +need a snapshot when you want to boot from a reusable custom filesystem image. + +```typescript +import { SandboxClient } from "langsmith/sandbox"; + +// Client uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment +const client = new SandboxClient(); + +// Create a sandbox with the default runtime and run code +const sandbox = await client.createSandbox(); +try { + const result = await sandbox.run("python -c 'print(2 + 2)'"); + console.log(result.stdout); // "4\n" + console.log(result.exit_code); // 0 +} finally { + await sandbox.delete(); +} + +// Or reuse an existing sandbox by name +const existingSb = await client.getSandbox("your-sandbox"); +const res = await existingSb.run("python -c 'print(2 + 2)'"); +``` + +If you have a reusable snapshot ID, pass it to `client.createSandbox(snapshotId)`. + +## Configuration + +The client automatically uses LangSmith environment variables: + +```typescript +import { SandboxClient } from "langsmith/sandbox"; + +// Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY +const client = new SandboxClient(); + +// Or configure explicitly +const client = new SandboxClient({ + apiEndpoint: "https://api.smith.langchain.com/v2/sandboxes", + apiKey: "your-api-key", + maxRetries: 3, // Retries for transient failures (default: 3) + maxConcurrency: 10, // Max concurrent requests (default: Infinity) +}); +``` + +## AWS Auth Proxy + +Use the AWS auth proxy when sandbox code needs to call AWS services such as S3, +Bedrock, or another supported AWS HTTPS endpoint. You configure the AWS +credentials on the sandbox proxy, and the proxy signs outbound AWS requests with +SigV4. The real credentials stay outside the sandbox. + +Store AWS credentials as LangSmith workspace secrets using names that make sense +for your workspace. Then reference those secret names in the proxy config: + +```typescript +import { + SandboxClient, + awsAuth, + proxyConfig, + workspaceSecret, +} from "langsmith/sandbox"; + +const client = new SandboxClient(); +const authConfig = proxyConfig({ + rules: [ + awsAuth({ + accessKeyId: workspaceSecret("SANDBOX_AWS_ACCESS_KEY_ID"), + secretAccessKey: workspaceSecret("SANDBOX_AWS_SECRET_ACCESS_KEY"), + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "aws-sandbox", + proxyConfig: authConfig, +}); + +try { + const result = await sandbox.run("node your-aws-script.js"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Inside `your-aws-script.js`, use AWS SDKs normally. For example, if +`@aws-sdk/client-s3` is installed in the sandbox snapshot: + +```typescript +import { S3Client, ListBucketsCommand } from "@aws-sdk/client-s3"; + +const s3 = new S3Client({}); +const response = await s3.send(new ListBucketsCommand({})); +console.log(response.Buckets?.map((bucket) => bucket.Name)); +``` + +If your application mints short-lived AWS credentials, pass them as write-only +opaque values instead: + +```typescript +import { awsAuth, opaqueSecret, proxyConfig } from "langsmith/sandbox"; + +const authConfig = proxyConfig({ + rules: [ + awsAuth({ + accessKeyId: opaqueSecret(accessKeyId), + secretAccessKey: opaqueSecret(secretAccessKey), + }), + ], +}); +``` + +Do not put real AWS credentials in sandbox environment variables. Plaintext AWS +credential values are not accepted directly by the AWS auth proxy; wrap +short-lived write-only values with `opaqueSecret(...)`. + +## GCP Auth Proxy + +Use the GCP auth proxy when sandbox code needs to call Google APIs. You +configure service account JSON on the sandbox proxy, and the proxy injects OAuth +bearer tokens for Google API hosts matched automatically by the sandbox proxy. +The real service account JSON stays outside the sandbox. + +Store the service account JSON as a LangSmith workspace secret, then reference +that secret name in the proxy config: + +```typescript +import { + SandboxClient, + gcpAuth, + proxyConfig, + workspaceSecret, +} from "langsmith/sandbox"; + +const client = new SandboxClient(); +const authConfig = proxyConfig({ + rules: [ + gcpAuth({ + serviceAccountJson: workspaceSecret("SANDBOX_GCP_SERVICE_ACCOUNT_JSON"), + scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "gcp-sandbox", + proxyConfig: authConfig, +}); + +try { + const result = await sandbox.run("node your-gcp-script.js"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Use `opaqueSecret("...")` for short-lived write-only service account JSON. +Plaintext service account JSON is not accepted directly. + +## Sandbox Mounts + +Use mounts when sandbox code needs filesystem access to external data such as +object storage buckets or public Git repositories. Mount specs contain only the +mount target. Provider credentials stay in `mountConfig.auth`; the backend +expands them into runtime proxy auth rules. You can also pass `proxyConfig` for +non-mount proxy behavior such as custom headers, callbacks, or access control. +Explicit AWS/GCP proxy auth rules conflict with `mountConfig` auth for the same +provider. + +S3 mounts require AWS auth: + +```typescript +import { + awsAuth, + mountConfig, + s3Mount, + workspaceSecret, +} from "langsmith/sandbox"; + +const mountCfg = mountConfig({ + auth: [ + awsAuth({ + accessKeyId: workspaceSecret("SANDBOX_AWS_ACCESS_KEY_ID"), + secretAccessKey: workspaceSecret("SANDBOX_AWS_SECRET_ACCESS_KEY"), + }), + ], + mounts: [ + s3Mount({ + id: "customer_data", + mountPath: "/mnt/mounts/customer-data", + bucket: "example-bucket", + prefix: "datasets/customer-data", + region: "us-east-1", + endpointUrl: "https://s3.amazonaws.com", + pathStyle: false, + readOnly: false, + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "s3-mount-sandbox", + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run("ls /mnt/mounts/customer-data"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +GCS mounts require GCP auth: + +```typescript +import { + gcpAuth, + gcsMount, + mountConfig, + workspaceSecret, +} from "langsmith/sandbox"; + +const mountCfg = mountConfig({ + auth: [ + gcpAuth({ + serviceAccountJson: workspaceSecret("SANDBOX_GCP_SERVICE_ACCOUNT_JSON"), + }), + ], + mounts: [ + gcsMount({ + id: "customer_data", + mountPath: "/mnt/mounts/customer-data", + bucket: "example-bucket", + prefix: "datasets/customer-data", + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "gcs-mount-sandbox", + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run("ls /mnt/mounts/customer-data"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Public Git mounts do not require AWS or GCP auth: + +```typescript +import { gitMount, mountConfig } from "langsmith/sandbox"; + +const mountCfg = mountConfig({ + mounts: [ + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + ref: { type: "branch", name: "main" }, + refreshIntervalSeconds: 60, + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "git-mount-sandbox", + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run("ls /mnt/repo"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Private Git repositories can use low-level `proxyConfig` rules when the remote +requires proxy-managed auth. There is not yet a high-level private Git auth +helper. + +Context Hub mounts are **read-only**: the repo's latest commit tree is mirrored +into the mount path and kept in sync for the sandbox's lifetime, but the sync is +one-way. Files written under the mount path inside the sandbox are never pushed +back to the repo, and the next sync overwrites them. Write sandbox output +somewhere else and push it with the Context Hub SDK if it belongs in the repo. + +They do not require AWS or GCP auth, and unlike bucket and Git mounts they can +target any path outside the system directories — not just paths under +`/mnt/mounts`. The caller's API key must have access to the repo: + +```typescript +import { contextHubMount, mountConfig } from "langsmith/sandbox"; + +const mountCfg = mountConfig({ + mounts: [ + contextHubMount({ + id: "memories", + mountPath: "/memories", + repo: "-/my-agent", + }), + ], +}); + +const sandbox = await client.createSandbox({ + name: "context-hub-mount-sandbox", + mountConfig: mountCfg, +}); + +try { + const result = await sandbox.run("ls /memories"); + console.log(result.stdout); +} finally { + await sandbox.delete(); +} +``` + +Pass `initialPullOnly: true` to sync once at startup instead of polling for repo +updates. + +If one sandbox needs S3, GCS, and Git mounts, build one `mountConfig` with the +bucket provider auth blocks and all mount specs: + +```typescript +import { + awsAuth, + gitMount, + gcpAuth, + gcsMount, + mountConfig, + s3Mount, + workspaceSecret, +} from "langsmith/sandbox"; + +const mountCfg = mountConfig({ + auth: [ + awsAuth({ + accessKeyId: workspaceSecret("SANDBOX_AWS_ACCESS_KEY_ID"), + secretAccessKey: workspaceSecret("SANDBOX_AWS_SECRET_ACCESS_KEY"), + }), + gcpAuth({ + serviceAccountJson: workspaceSecret("SANDBOX_GCP_SERVICE_ACCOUNT_JSON"), + }), + ], + mounts: [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/mounts/s3-data", + bucket: "example-s3-bucket", + }), + gcsMount({ + id: "gcs_data", + mountPath: "/mnt/mounts/gcs-data", + bucket: "example-gcs-bucket", + }), + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + }), + ], +}); +``` + +## Running Commands + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Run a command + const result = await sandbox.run("echo 'Hello, World!'"); + + console.log(result.stdout); // "Hello, World!\n" + console.log(result.stderr); // "" + console.log(result.exit_code); // 0 + + // Commands that fail return non-zero exit codes + const failed = await sandbox.run("exit 1"); + console.log(failed.exit_code); // 1 + + // Pass environment variables and working directory + const envResult = await sandbox.run("echo $MY_VAR", { + env: { MY_VAR: "test-value" }, + cwd: "/tmp", + }); +} finally { + await sandbox.delete(); +} +``` + +## Streaming Execution (WebSocket) + +For long-running commands, you can stream output in real-time using WebSocket-based execution. This requires the optional `ws` package: + +```bash +npm install ws +``` + +### Stream with callbacks + +```typescript +// Stream stdout/stderr via callbacks (blocks until complete) +const result = await sandbox.run("make build", { + onStdout: (data) => process.stdout.write(data), + onStderr: (data) => process.stderr.write(data), +}); +console.log(`Exit code: ${result.exit_code}`); +``` + +### Non-blocking with CommandHandle + +```typescript +// Get a CommandHandle for full control over the stream +const handle = await sandbox.run("python train.py", { + wait: false, + timeout: 600, +}); + +console.log(`Command ID: ${handle.commandId}`); +console.log(`PID: ${handle.pid}`); + +// Iterate over output chunks (auto-reconnects on transient errors) +for await (const chunk of handle) { + if (chunk.stream === "stdout") { + process.stdout.write(chunk.data); + } else { + process.stderr.write(chunk.data); + } +} + +// Get the final result +const result = await handle.result; +console.log(`Exit code: ${result.exit_code}`); +``` + +### Sending stdin and killing commands + +```typescript +const handle = await sandbox.run("python -i", { wait: false }); + +// Send input to stdin +handle.sendInput("print(2 + 2)\n"); +handle.sendInput("exit()\n"); + +for await (const chunk of handle) { + process.stdout.write(chunk.data); +} +``` + +```typescript +const handle = await sandbox.run("sleep 300", { wait: false }); + +// Kill the running command +handle.kill(); + +const result = await handle.result; +console.log(result.exit_code); // non-zero +``` + +### Reconnecting to a running command + +```typescript +// If a client disconnects, you can reconnect using the command ID +const handle = await sandbox.run("long-task", { wait: false }); +const commandId = handle.commandId; + +// ... later, or from a different client ... +const newHandle = await sandbox.reconnect(commandId); +for await (const chunk of newHandle) { + process.stdout.write(chunk.data); +} +``` + +> **Note:** When `wait` is `true` (default) with no callbacks, `run()` automatically tries WebSocket first and falls back to HTTP POST if the `ws` package is not installed or the server doesn't support it. + +## Command Lifecycle & TTL + +The sandbox daemon automatically manages command session lifecycles with two +timeout mechanisms: + +### Session TTL (finished commands) + +After a command finishes (exits), its session remains in memory for a TTL +period. During this window you can still reconnect to retrieve output. After the +TTL expires, the session is cleaned up and `reconnect()` will throw an error. + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + const handle = await sandbox.run("make build", { wait: false }); + const commandId = handle.commandId; + + // Even after the command finishes, you can reconnect within the TTL window + const newHandle = await sandbox.reconnect(commandId); + const result = await newHandle.result; + console.log(result.stdout); + + // After TTL expires, reconnect throws LangSmithSandboxOperationError +} finally { + await sandbox.delete(); +} +``` + +### Idle Timeout (running commands) + +Running commands with no connected clients are killed after an idle timeout +(default: 5 minutes). The idle timer resets each time a client connects. This +prevents orphaned long-running processes from consuming resources indefinitely. + +You can set a per-command idle timeout via the `idleTimeout` option. +Set to `-1` for no idle timeout (the command runs indefinitely until explicitly +killed or it exits on its own). + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Start a long-running command with a 30-minute idle timeout + const handle = await sandbox.run("python server.py", { + timeout: 0, + idleTimeout: 1800, + wait: false, + }); + + // As long as a client is connected (iterating), the idle timer is paused + for await (const chunk of handle) { + process.stdout.write(chunk.data); + if (chunk.data.includes("Ready")) break; + } + + // After disconnecting, the idle timer starts + // If no client reconnects within idleTimeout seconds, the process is killed +} finally { + await sandbox.delete(); +} +``` + +### Kill on Disconnect + +By default, commands continue running after a client disconnects and can be +reconnected to later. Set `killOnDisconnect: true` to kill the command +immediately when the last client disconnects: + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Command is killed as soon as the client disconnects + const handle = await sandbox.run("python server.py", { + killOnDisconnect: true, + wait: false, + }); + + for await (const chunk of handle) { + process.stdout.write(chunk.data); + if (chunk.data.includes("Ready")) break; + } + // Command is killed here when iteration stops and the WS disconnects +} finally { + await sandbox.delete(); +} +``` + +### Combining Lifecycle Options + +All lifecycle options can be combined: + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Long-running task: 30-min idle timeout, 1-hour session TTL + const handle = await sandbox.run("python train.py", { + timeout: 0, // No command timeout + idleTimeout: 1800, // Kill after 30min with no clients + ttlSeconds: 3600, // Keep session for 1 hour after exit + wait: false, + }); + + // Fire-and-forget: no idle timeout, infinite TTL + const bg = await sandbox.run("python background_job.py", { + timeout: 0, + idleTimeout: -1, // Never kill due to idle + ttlSeconds: -1, // Keep session forever + wait: false, + }); +} finally { + await sandbox.delete(); +} +``` + +## PTY (Pseudo-Terminal) + +Set `pty: true` to allocate a pseudo-terminal for the command. This is useful +for interactive programs and commands that detect terminal capabilities: + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Run an interactive Python REPL with PTY + const handle = await sandbox.run("python", { pty: true, wait: false }); + + for await (const chunk of handle) { + if (chunk.data.includes(">>>")) { + handle.sendInput("print('hello')\n"); + break; + } + } + + for await (const chunk of handle) { + if (chunk.data.includes(">>>")) { + handle.sendInput("exit()\n"); + break; + } + } + + const result = await handle.result; + + // Commands that require a TTY + const topResult = await sandbox.run("top -b -n 1", { pty: true }); +} finally { + await sandbox.delete(); +} +``` + +> **Note:** PTY mode merges stdout and stderr into a single stream (stdout). +> Only use PTY when the command requires it — most commands work fine without it. + +## File Operations + +Read and write files in the sandbox: + +```typescript +const sandbox = await client.createSandbox(snapshot.id); +try { + // Write a file (string content) + await sandbox.write("/app/script.py", "print('Hello from file!')"); + + // Run the script + const result = await sandbox.run("python /app/script.py"); + console.log(result.stdout); // "Hello from file!\n" + + // Read a file (returns Uint8Array) + const content = await sandbox.read("/app/script.py"); + console.log(new TextDecoder().decode(content)); // "print('Hello from file!')" + + // Write binary files + await sandbox.write( + "/app/data.bin", + new Uint8Array([0x00, 0x01, 0x02, 0x03]), + ); +} finally { + await sandbox.delete(); +} +``` + +## Verifying Requests from LangSmith + +`SandboxTokenVerifier` checks EdDSA signatures against LangSmith's JWKS +(derived from `LANGSMITH_ENDPOINT`, cached) using Web Crypto, so it needs a +runtime with Ed25519 support (Node.js 20+, Deno, Bun, Cloudflare Workers). The +JWKS must be served over HTTPS (localhost is exempt); for a plain-HTTP +self-hosted deployment on a trusted network, pass `allowInsecureJwks: true`. + +### Service URL Users + +Apps served from a service URL with LangSmith login receive the caller's +identity in `X-Langsmith-User-Id` / `X-Langsmith-User-Email`. The sandbox +runtime strips these from inbound requests and sets them itself, so code that +knows it is running in a sandbox can trust them as-is. This holds only for +requests that arrive through the service URL: TCP tunnels and other processes in +the sandbox calling the port over localhost reach the app with whatever headers +they choose. Otherwise, verify the signed `X-Langsmith-User-Token` header, which +carries the same identity: + +```typescript +import { SandboxTokenVerifier, USER_TOKEN_HEADER } from "langsmith/sandbox"; + +const verifier = new SandboxTokenVerifier(); + +const user = await verifier.verifyUserToken( + req.headers.get(USER_TOKEN_HEADER)!, + { + audience: req.headers.get("host")!, // the service URL host + }, +); +console.log(user.subject, user.email); +``` + +### Proxy Callbacks + +Callback endpoints receive a body signed via the `X-LangSmith-Signature-JWT` +header. Pass the raw body. Optionally pass `aud` to check the signature was +minted for this endpoint: either the callback URL exactly as configured, or a +predicate called with the signature's audience: + +```typescript +import { CALLBACK_SIGNATURE_HEADER } from "langsmith/sandbox"; + +const callback = await verifier.verifyCallback({ + body: await req.text(), + signature: req.headers.get(CALLBACK_SIGNATURE_HEADER)!, + aud: "https://example.com/sandbox-callback", +}); +console.log(callback.identity.sandbox_id, callback.host); +``` + +Both throw `LangSmithSandboxTokenVerificationError` on failure. + +## Snapshots + +Snapshots are the filesystem images sandboxes boot from. You can build one from +a Docker image or capture the state of a running sandbox. + +```typescript +// Build a snapshot from a public Docker image +const snapshot = await client.createSnapshot( + "python", + "python:3.12-slim", + 1_073_741_824, // 1 GiB +); + +// Create a private registry once, then reference it by id. Store the password +// in a secret, not in source. +const registry = await client.sandboxes.registries.create({ + name: "internal", + url: "registry.example.com", + username: "robot", + password: "...", +}); + +// Build from a private registry +const privateSnapshot = await client.createSnapshot( + "internal-python", + "registry.example.com/internal/python:3.12", + 2_147_483_648, + { registryId: registry.id }, +); + +// Capture the state of a running sandbox for later reuse. Persistent paths +// (`/usr/local`, `/root`, `/opt`, the home directory, etc.) are preserved; +// `/tmp` is a tmpfs and is NOT part of the capture. +const running = await client.createSandbox(snapshot.id); +await running.run("pip install --quiet requests", { timeout: 180 }); +await running.write("/opt/prepared.txt", "preloaded"); + +// Either form works; the instance method just forwards to the client. +const captured = await running.captureSnapshot("with-data", { timeout: 300 }); +// const captured = await client.captureSnapshot(running.name, "with-data"); + +console.log(captured.id, captured.source_sandbox_id); + +// Boot a new sandbox from the captured snapshot +const resumed = await client.createSandbox(captured.id); + +// Or resolve by snapshot name instead of ID. This is optional; omitting both +// snapshotId and snapshotName uses the default runtime. +const byName = await client.createSandbox({ + snapshotName: "with-data", +}); + +// List / fetch / delete snapshots. listSnapshots() accepts optional +// server-side filters and pagination; when omitted the server applies a +// default page size of 50, so calling it once will not necessarily return +// every snapshot. `limit` must be between 1 and 500 and `offset` must be +// >= 0. +const firstPage = await client.listSnapshots(); +const firstPython = await client.listSnapshots({ + nameContains: "python", + limit: 100, + offset: 0, +}); +const loaded = await client.getSnapshot(snapshot.id); +await client.deleteSnapshot(snapshot.id); +``` + +> **Note:** `captureSnapshot` preserves only the persistent filesystem. Running +> processes, open sockets, in-memory state, and anything under `/tmp` are not +> carried over — restart the processes you need in the new sandbox. + +### Sizing and Resources + +`createSandbox` accepts optional per-sandbox resource limits. If omitted, the +server-side defaults are used — prefer that unless you have a specific need. + +```typescript +const sandbox = await client.createSandbox({ + fsCapacityBytes: 5_368_709_120, // 5 GiB +}); +``` + +`vCpus` and `memBytes` may also be set, but they are validated together: +`memBytes` must be within 50% of 4 GiB per vCPU, so a 2-vCPU sandbox accepts +4–12 GiB. Requests outside that range are rejected. + +```typescript +const sandbox = await client.createSandbox({ + vCpus: 2, + memBytes: 8_589_934_592, // 8 GiB +}); +``` + +## Reusing Existing Sandboxes + +Get a sandbox that's already running: + +```typescript +// Create a sandbox (requires explicit cleanup) +const sb = await client.createSandbox(snapshot.id); +console.log(sb.name); // e.g., "sandbox-abc123" + +// Later, get the same sandbox +const existingSb = await client.getSandbox("sandbox-abc123"); +const result = await existingSb.run("echo 'Still running!'"); + +// Clean up when done +await client.deleteSandbox("sandbox-abc123"); +``` + +## Error Handling + +The module provides typed exceptions for specific error handling: + +```typescript +import { + SandboxClient, + LangSmithSandboxError, // Base exception for all sandbox errors + LangSmithResourceNotFoundError, // Resource doesn't exist (check resourceType) + LangSmithResourceTimeoutError, // Operation timed out (check resourceType) + LangSmithSandboxConnectionError, // Network error + LangSmithSandboxServerReloadError, // Server hot-reload (auto-reconnects) + LangSmithCommandTimeoutError, // Command exceeded its timeout + LangSmithQuotaExceededError, // Quota limit reached + LangSmithValidationError, // Invalid input +} from "langsmith/sandbox"; + +const client = new SandboxClient(); + +try { + // This will fail if the snapshot doesn't exist + const sandbox = await client.createSandbox("nonexistent"); + await sandbox.delete(); +} catch (e) { + if (e instanceof LangSmithResourceNotFoundError) { + // e.resourceType tells you what wasn't found: "sandbox", "snapshot", "file" + console.log(`${e.resourceType} not found: ${e.message}`); + } else if (e instanceof LangSmithResourceTimeoutError) { + console.log(`Timeout waiting for ${e.resourceType}: ${e.message}`); + } else if (e instanceof LangSmithSandboxError) { + console.log(`Error: ${e.message}`); + } +} +``` + +## API Reference + +### SandboxClient + +| Method | Description | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | +| `createSandbox(options?)` / `createSandbox(snapshotId?, options?)` | Create a sandbox. Pass `snapshotId` or `options.snapshotName` to boot from a reusable snapshot. | +| `getSandbox(name)` | Get an existing sandbox by name | +| `listSandboxes()` | List all sandboxes | +| `updateSandbox(name, options)` | Rename or adjust retention (idle stop, delete-after-stop) on a sandbox | +| `deleteSandbox(name)` | Delete a sandbox | +| `startSandbox(name, options?)` | Start a stopped sandbox and wait until ready | +| `stopSandbox(name)` | Stop a running sandbox (preserves files) | +| `getSandboxStatus(name)` | Lightweight status poll for async creation | +| `waitForSandbox(name, options?)` | Poll until a sandbox becomes ready | +| `createSnapshot(name, dockerImage, fsCapacityBytes, options?)` | Build a snapshot from a Docker image | +| `captureSnapshot(sandboxName, name, options?)` | Capture a snapshot from a running sandbox | +| `getSnapshot(snapshotId)` | Get a snapshot by ID | +| `listSnapshots(options?)` | List a page of snapshots with optional server-side filters and pagination (server paginates, default limit 50, max 500). | +| `deleteSnapshot(snapshotId)` | Delete a snapshot | +| `waitForSnapshot(snapshotId, options?)` | Poll until a snapshot becomes ready | + +### Sandbox + +| Method | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `run(command, options?)` | Execute a shell command (returns `ExecutionResult` or `CommandHandle`). Supports `idleTimeout` option. | +| `reconnect(commandId, options?)` | Reconnect to a running command by ID | +| `write(path, content, timeout?)` | Write file (string or Uint8Array) | +| `read(path, timeout?)` | Read file (returns Uint8Array) | +| `start(options?)` | Start this sandbox (if stopped) | +| `stop()` | Stop this sandbox | +| `captureSnapshot(name, options?)` | Capture a snapshot from this sandbox | +| `delete()` | Delete the sandbox | + +### CommandHandle + +| Property/Method | Description | +| ------------------------ | ------------------------------------------------- | +| `commandId` | Server-assigned command ID | +| `pid` | Process ID on the sandbox | +| `result` | Final `ExecutionResult` (drains stream if needed) | +| `kill()` | Send SIGKILL to the running command | +| `sendInput(data)` | Write string data to the command's stdin | +| `reconnect()` | Reconnect from the last known offsets | +| `lastStdoutOffset` | Last stdout byte offset (for manual reconnection) | +| `lastStderrOffset` | Last stderr byte offset (for manual reconnection) | +| `[Symbol.asyncIterator]` | Yields `OutputChunk` objects with auto-reconnect | + +### ExecutionResult + +| Property | Description | +| ----------- | ------------------------ | +| `stdout` | Standard output (string) | +| `stderr` | Standard error (string) | +| `exit_code` | Exit code (number) | + +### OutputChunk + +| Property | Description | +| -------- | ----------------------------- | +| `stream` | `"stdout"` or `"stderr"` | +| `data` | Text content of the chunk | +| `offset` | Byte offset within the stream | + +### CreateSandboxOptions + +| Property | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `snapshotName?` | Optional snapshot name to boot from. Mutually exclusive with the positional `snapshotId`. | +| `name?` | Custom sandbox name | +| `timeout?` | Wait timeout in seconds | +| `waitForReady?` | Wait for the sandbox to be ready before returning (default: `true`) | +| `idleTtlSeconds?` | Idle timeout in seconds (multiple of 60; `0` disables idle stop). When omitted, the server applies a default of `600` seconds (10 minutes). | +| `deleteAfterStopSeconds?` | Seconds after the sandbox enters `stopped` before deletion (multiple of 60; `0` disables stop-anchored deletion). When omitted, the server applies its configured default. | +| `vCpus?` | Number of vCPUs | +| `memBytes?` | Memory allocation in bytes | +| `fsCapacityBytes?` | Root filesystem capacity in bytes | +| `mountConfig?` | High-level mount config from `mountConfig({ mounts, auth })`; sent as `mount_config` and expanded by the backend at runtime | +| `proxyConfig?` | Per-sandbox proxy configuration (access control, rules, callbacks) | + +### ListSnapshotsOptions + +| Property | Description | +| --------------- | ----------------------------------------------------------------------------------------- | +| `nameContains?` | Case-insensitive substring filter applied server-side to snapshot names | +| `limit?` | Page size; must be in `[1, 500]`. Server defaults to 50 when omitted | +| `offset?` | Number of snapshots to skip before returning results (must be `>= 0`, pairs with `limit`) | +| `signal?` | `AbortSignal` for cancellation | + +### CreateSnapshotOptions + +| Property | Description | +| ------------- | ------------------------------------- | +| `registryId?` | Private registry ID | +| `timeout?` | Wait timeout in seconds (default: 60) | +| `signal?` | `AbortSignal` for cancellation | + +### UpdateSandboxOptions + +| Property | Description | +| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `newName?` | New display name | +| `idleTtlSeconds?` | Idle timeout in seconds (multiple of 60; `0` disables idle stop). Omit to leave the existing value unchanged. | +| `deleteAfterStopSeconds?` | Seconds after the sandbox enters `stopped` before deletion (multiple of 60; `0` disables). Omit to leave the existing value unchanged. | + +### RunOptions + +| Property | Description | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `timeout?` | Execution timeout in seconds (default: 60) | +| `env?` | Environment variables for the command | +| `cwd?` | Working directory | +| `shell?` | Shell to use (default: `"/bin/bash"`) | +| `wait?` | Wait for completion (default: `true`). When `false`, returns `CommandHandle` | +| `onStdout?` | Callback invoked with each stdout chunk (triggers WS streaming) | +| `onStderr?` | Callback invoked with each stderr chunk (triggers WS streaming) | +| `idleTimeout?` | Idle timeout in seconds (default: 300). Set to -1 for no timeout. Kills the command if no clients are connected for this duration | +| `killOnDisconnect?` | If true, kill the command immediately when the last client disconnects (default: false) | +| `ttlSeconds?` | How long a finished command's session is kept for reconnection (default: 600). Set to -1 to keep indefinitely | +| `pty?` | Allocate a pseudo-terminal (default: false). Merges stderr into stdout | diff --git a/src/lib/sandbox/access_delegation.ts b/src/lib/sandbox/access_delegation.ts new file mode 100644 index 0000000..253196e --- /dev/null +++ b/src/lib/sandbox/access_delegation.ts @@ -0,0 +1,54 @@ +import { LangSmithValidationError } from "./errors.js"; + +/** How a delegation grant derives its permission set. */ +export type AccessDelegationMode = "INHERIT" | "EXPLICIT"; + +/** + * Grant letting code inside a sandbox call the LangSmith API as the creator. + * + * `INHERIT` tracks everything the creator can do; `EXPLICIT` is capped to + * `permissions`. Permissions are a ceiling re-checked on every request rather + * than a snapshot, so access the creator loses is lost here too. + */ +export interface AccessDelegation { + mode: AccessDelegationMode; + permissions?: string[]; +} + +/** Rejects grants the server would refuse, without the round trip. */ +export function validateAccessDelegation( + value: AccessDelegation, +): AccessDelegation { + if (value.mode !== "INHERIT" && value.mode !== "EXPLICIT") { + throw new LangSmithValidationError( + 'accessDelegation.mode must be "INHERIT" or "EXPLICIT"', + "accessDelegation", + ); + } + + const { permissions } = value; + if (permissions !== undefined && !Array.isArray(permissions)) { + throw new LangSmithValidationError( + "accessDelegation.permissions must be an array of strings", + "accessDelegation", + ); + } + + if (value.mode === "INHERIT") { + if (permissions !== undefined && permissions.length > 0) { + throw new LangSmithValidationError( + 'accessDelegation.permissions is not allowed with mode "INHERIT"', + "accessDelegation", + ); + } + return { mode: "INHERIT" }; + } + + if (permissions === undefined || permissions.length === 0) { + throw new LangSmithValidationError( + 'accessDelegation.permissions is required with mode "EXPLICIT"', + "accessDelegation", + ); + } + return { mode: "EXPLICIT", permissions: [...permissions] }; +} diff --git a/src/lib/sandbox/client.ts b/src/lib/sandbox/client.ts new file mode 100644 index 0000000..fb84c17 --- /dev/null +++ b/src/lib/sandbox/client.ts @@ -0,0 +1,1440 @@ +/** + * Main SandboxClient class for interacting with the sandbox server API. + */ + +import { validateAccessDelegation } from "./access_delegation.js"; +import { + ServiceLoginUrl, + ServiceUrl, + type ServiceAccess, +} from "./service_url.js"; +import { getLangSmithEnvironmentVariable } from "../utils/env.js"; +import { _getFetchImplementation } from "../singletons/fetch.js"; +import { AsyncCaller } from "../utils/async_caller.js"; +import { Langsmith as OpenAPILangsmith } from "../../client.js"; +import { Registries } from "../../resources/sandboxes/registries.js"; +import type { + CaptureSnapshotOptions, + CreateDockerfileSnapshotOptions, + CreateSandboxOptions, + CreateSnapshotOptions, + DownloadURL, + GenerateDownloadURLOptions, + ListSnapshotsOptions, + ResourceStatus, + SandboxClientConfig, + SandboxData, + Snapshot, + StartSandboxOptions, + UpdateSandboxOptions, + WaitForSandboxOptions, + WaitForSnapshotOptions, +} from "./types.js"; +import { Sandbox } from "./sandbox.js"; +import { + LangSmithResourceCreationError, + LangSmithResourceNameConflictError, + LangSmithResourceNotFoundError, + LangSmithResourceTimeoutError, + LangSmithSandboxAPIError, + LangSmithValidationError, +} from "./errors.js"; +import { + handleClientHttpError, + handleSandboxCreationError, + throwIfNotReady, + validateTtl, +} from "./helpers.js"; +import { validateMountConfigProxyConfig } from "./mounts.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; + +/** + * Sleep that can be interrupted by an AbortSignal. + * Resolves after `ms` milliseconds or rejects immediately if the signal fires. + */ +function sleepWithSignal(ms: number, signal?: AbortSignal): Promise { + if (!signal) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + const abortSignal = signal; + abortSignal.throwIfAborted(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + abortSignal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + function onAbort() { + clearTimeout(timer); + reject(abortSignal.reason); + } + abortSignal.addEventListener("abort", onAbort, { once: true }); + }); +} + +/** + * Get the default sandbox API endpoint from environment. + * + * Derives the endpoint from LANGSMITH_ENDPOINT (or LANGCHAIN_ENDPOINT). + */ +function getDefaultApiEndpoint(): string { + const base = + getLangSmithEnvironmentVariable("ENDPOINT") ?? + "https://api.smith.langchain.com"; + return `${base.replace(/\/$/, "")}/v2/sandboxes`; +} + +/** + * Get the default API key from environment. + */ +function getDefaultApiKey(): string | undefined { + return getLangSmithEnvironmentVariable("API_KEY"); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function writeString( + header: Buffer, + value: string, + offset: number, + length: number, +): void { + header.write(value.slice(0, length), offset, length, "utf8"); +} + +function writeOctal( + header: Buffer, + value: number, + offset: number, + length: number, +): void { + const octal = value.toString(8).padStart(length - 1, "0"); + header.write(octal.slice(-length + 1) + "\0", offset, length, "ascii"); +} + +function splitTarPath(name: string): { name: string; prefix: string } { + if (Buffer.byteLength(name) <= 100) { + return { name, prefix: "" }; + } + const parts = name.split("/"); + for (let i = 1; i < parts.length; i += 1) { + const prefix = parts.slice(0, i).join("/"); + const basename = parts.slice(i).join("/"); + if ( + Buffer.byteLength(prefix) <= 155 && + Buffer.byteLength(basename) <= 100 + ) { + return { name: basename, prefix }; + } + } + throw new Error(`Docker build context path is too long for tar: ${name}`); +} + +function makeTarHeader(args: { + name: string; + mode: number; + size: number; + type: "file" | "directory" | "symlink"; + linkName?: string; + mtimeMs: number; +}): Buffer { + const header = Buffer.alloc(512, 0); + const split = splitTarPath(args.name); + writeString(header, split.name, 0, 100); + writeOctal(header, args.mode, 100, 8); + writeOctal(header, 0, 108, 8); + writeOctal(header, 0, 116, 8); + writeOctal(header, args.size, 124, 12); + writeOctal(header, Math.floor(args.mtimeMs / 1000), 136, 12); + header.fill(" ", 148, 156); + writeString( + header, + args.type === "directory" ? "5" : args.type === "symlink" ? "2" : "0", + 156, + 1, + ); + if (args.linkName) { + writeString(header, args.linkName, 157, 100); + } + writeString(header, "ustar", 257, 6); + writeString(header, "00", 263, 2); + if (split.prefix) { + writeString(header, split.prefix, 345, 155); + } + let checksum = 0; + for (const byte of header) { + checksum += byte; + } + header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, 8, "ascii"); + return header; +} + +async function makeDockerContextTar(contextPath: string): Promise { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const contextRoot = path.resolve(contextPath); + const chunks: Buffer[] = []; + + async function addEntry(absPath: string): Promise { + const rel = path.relative(contextRoot, absPath); + if (!rel || rel.split(path.sep).includes(".git")) { + return; + } + const tarPath = rel.split(path.sep).join("/"); + const stat = await fs.lstat(absPath); + if (stat.isDirectory()) { + chunks.push( + makeTarHeader({ + name: tarPath.endsWith("/") ? tarPath : `${tarPath}/`, + mode: stat.mode & 0o777, + size: 0, + type: "directory", + mtimeMs: stat.mtimeMs, + }), + ); + const entries = await fs.readdir(absPath); + for (const entry of entries.sort()) { + await addEntry(path.join(absPath, entry)); + } + return; + } + if (stat.isSymbolicLink()) { + chunks.push( + makeTarHeader({ + name: tarPath, + mode: stat.mode & 0o777, + size: 0, + type: "symlink", + linkName: await fs.readlink(absPath), + mtimeMs: stat.mtimeMs, + }), + ); + return; + } + if (!stat.isFile()) { + return; + } + const content = await fs.readFile(absPath); + chunks.push( + makeTarHeader({ + name: tarPath, + mode: stat.mode & 0o777, + size: content.byteLength, + type: "file", + mtimeMs: stat.mtimeMs, + }), + content, + ); + const padding = (512 - (content.byteLength % 512)) % 512; + if (padding) { + chunks.push(Buffer.alloc(padding, 0)); + } + } + + const rootEntries = await fs.readdir(contextRoot); + for (const entry of rootEntries.sort()) { + await addEntry(path.join(contextRoot, entry)); + } + chunks.push(Buffer.alloc(1024, 0)); + return new Uint8Array(Buffer.concat(chunks)); +} + +async function resolveDockerfileContext( + dockerfile: string, + context: string, +): Promise<{ contextPath: string; dockerfileRel: string }> { + const fs = await import("node:fs/promises"); + const path = await import("node:path"); + const contextPath = path.resolve(context); + const dockerfilePath = path.resolve(contextPath, dockerfile); + const contextStat = await fs.stat(contextPath); + if (!contextStat.isDirectory()) { + throw new Error(`context must be a directory: ${contextPath}`); + } + const dockerfileStat = await fs.stat(dockerfilePath); + if (!dockerfileStat.isFile()) { + throw new Error(`dockerfile must be a file: ${dockerfilePath}`); + } + const dockerfileRel = path.relative(contextPath, dockerfilePath); + if ( + dockerfileRel === "" || + dockerfileRel.startsWith("..") || + path.isAbsolute(dockerfileRel) + ) { + throw new Error("dockerfile must be inside context"); + } + return { + contextPath, + dockerfileRel: dockerfileRel.split(path.sep).join("/"), + }; +} + +function makeDockerfileBuildCommand(args: { + remoteContext: string; + dockerfileRel: string; + imageRef: string; + buildkitRoot: string; + buildkitRun: string; + buildArgs?: Record; + target?: string; +}): string { + const dockerfileRemote = `${args.remoteContext}/${args.dockerfileRel}`; + const dockerfileDir = dockerfileRemote.split("/").slice(0, -1).join("/"); + const dockerfileName = dockerfileRemote.split("/").at(-1) ?? "Dockerfile"; + const socketPath = `${args.buildkitRun}/buildkitd.sock`; + const buildctl = [ + "buildctl", + "--addr", + `unix://${socketPath}`, + "build", + "--progress=plain", + "--frontend", + "dockerfile.v0", + "--local", + `context=${args.remoteContext}`, + "--local", + `dockerfile=${dockerfileDir}`, + "--opt", + `filename=${dockerfileName}`, + "--output", + `type=docker,name=${args.imageRef}`, + ]; + if (args.target !== undefined) { + buildctl.push("--opt", `target=${args.target}`); + } + for (const [key, value] of Object.entries(args.buildArgs ?? {}).sort()) { + buildctl.push("--opt", `build-arg:${key}=${value}`); + } + return [ + "set -euo pipefail", + `mkdir -p ${shellQuote(args.buildkitRoot)} ${shellQuote(args.buildkitRun)}`, + `buildkitd --addr ${shellQuote(`unix://${socketPath}`)} --root ${shellQuote( + args.buildkitRoot, + )} --oci-worker=true --containerd-worker=false --oci-worker-snapshotter=native --oci-worker-binary buildkit-runc > ${shellQuote( + `${args.buildkitRun}/buildkitd.log`, + )} 2>&1 &`, + "buildkitd_pid=$!", + 'cleanup() { kill "$buildkitd_pid" >/dev/null 2>&1 || true; }', + "trap cleanup EXIT", + "for i in $(seq 1 300); do", + ` if buildctl --addr ${shellQuote( + `unix://${socketPath}`, + )} debug workers >/dev/null 2>&1; then break; fi`, + ` if ! kill -0 "$buildkitd_pid" >/dev/null 2>&1; then cat ${shellQuote( + `${args.buildkitRun}/buildkitd.log`, + )}; exit 1; fi`, + ` if [ "$i" = 300 ]; then cat ${shellQuote( + `${args.buildkitRun}/buildkitd.log`, + )}; exit 1; fi`, + " sleep 0.1", + "done", + "for i in $(seq 1 300); do", + " if docker info >/dev/null 2>&1; then break; fi", + ' if [ "$i" = 300 ]; then docker info; exit 1; fi', + " sleep 0.1", + "done", + `${buildctl.map(shellQuote).join(" ")} | docker load`, + `rm -rf ${shellQuote(args.buildkitRoot)} || true`, + "", + ].join("\n"); +} + +/** + * Client for interacting with the Sandbox Server API. + * + * This client provides a simple interface for managing sandboxes and snapshots. + * + * @example + * ```typescript + * import { SandboxClient } from "langsmith/sandbox"; + * + * // Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment + * const client = new SandboxClient(); + * + * // Or with explicit configuration + * const client = new SandboxClient({ + * apiEndpoint: "https://api.smith.langchain.com/v2/sandboxes", + * apiKey: "your-api-key", + * }); + * + * // Create a sandbox with the default runtime + * const sandbox = await client.createSandbox(); + * try { + * const result = await sandbox.run("python --version"); + * console.log(result.stdout); + * } finally { + * await sandbox.delete(); + * } + * ``` + */ +export class SandboxClient { + private _baseUrl: string; + private _apiKey?: string; + private _defaultHeaders: Record; + private _fetchImpl: typeof fetch; + private _caller: AsyncCaller; + private _registriesClient?: OpenAPILangsmith; + + constructor(config: SandboxClientConfig = {}) { + this._baseUrl = (config.apiEndpoint ?? getDefaultApiEndpoint()).replace( + /\/$/, + "", + ); + this._apiKey = config.apiKey ?? getDefaultApiKey(); + this._defaultHeaders = { ...(config.headers ?? {}) }; + this._fetchImpl = _getFetchImplementation(); + this._caller = new AsyncCaller({ + maxRetries: config.maxRetries ?? 3, + maxConcurrency: config.maxConcurrency ?? Infinity, + }); + } + + private _apiRoot(): string { + const suffix = "/v2/sandboxes"; + return this._baseUrl.endsWith(suffix) + ? this._baseUrl.slice(0, -suffix.length) + : this._baseUrl; + } + + /** + * Manage sandbox image registries: create, list, retrieve, update, delete. + * + * A registry stores credentials for pulling private images. Create one, then + * pass its `id` as `registryId` when building a snapshot. + * + * @example + * ```typescript + * const registry = await client.registries.create({ + * name: "internal", + * url: "registry.example.com", + * username: "robot", + * password: process.env.REGISTRY_PASSWORD, + * }); + * const snapshot = await client.createSnapshot( + * "internal-python", + * "registry.example.com/internal/python:3.12", + * 2_147_483_648, + * { registryId: registry.id }, + * ); + * ``` + */ + get registries(): Registries { + if (!this._registriesClient) { + const defaultHeaders: Record = { + ...this._defaultHeaders, + }; + if (this._apiKey === undefined) { + defaultHeaders["X-API-Key"] = null; + } + this._registriesClient = new OpenAPILangsmith({ + apiKey: this._apiKey, + baseURL: this._apiRoot(), + defaultHeaders, + fetch: this._fetchImpl, + }); + } + return this._registriesClient.sandboxes.registries; + } + + /** + * Internal fetch method that adds authentication headers. + * + * Uses AsyncCaller to handle retries for transient failures + * (network errors, 5xx, 429). + * + * @internal + */ + async _fetch(url: string, init: RequestInit = {}): Promise { + const headers = new Headers(init.headers); + if (this._apiKey) { + headers.set("X-Api-Key", this._apiKey); + } + for (const [name, value] of Object.entries(this._defaultHeaders)) { + if (!headers.has(name)) { + headers.set(name, value); + } + } + return this._caller.call(() => + this._fetchImpl(url, { + ...init, + headers, + }), + ); + } + + /** + * Get the API key for WebSocket authentication. + * @internal + */ + getApiKey(): string | undefined { + return this._apiKey; + } + + /** + * Get the constructor-supplied default headers. Used by the WebSocket exec + * path so headers like `X-Service-Key` set on the client are attached to + * the WS upgrade request. + * @internal + */ + getDefaultHeaders(): Record { + return { ...this._defaultHeaders }; + } + + /** + * JSON POST helper. Sends JSON body, checks response status, + * and returns the Response for further processing. + * Throws on non-ok responses via handleClientHttpError. + * Callers can add specific status checks (e.g. 404) before calling this. + * @internal + */ + private _boxUrl(name: string, ...segments: string[]): string { + const suffix = segments.length ? `/${segments.join("/")}` : ""; + return `${this._baseUrl}/boxes/${encodeURIComponent(name)}${suffix}`; + } + + /** + * Generate an authenticated URL for an HTTP service running in a sandbox. + * + * Without `access`, mints a short-lived token and returns a {@link ServiceUrl} + * that refreshes it as it nears expiry. With `access` set to `"restricted"` + * or `"workspace"` the URL is gated by LangSmith login instead and a + * {@link ServiceLoginUrl} is returned: no token, no expiry, browser only. + * + * A login grant is durable, so token mode is refused with 409 while one is + * in place, and `expiresInSeconds` does not apply to the login modes. + */ + async serviceUrl( + name: string, + options: { + port: number; + expiresInSeconds?: number; + access?: ServiceAccess; + signal?: AbortSignal; + }, + ): Promise { + const { port, expiresInSeconds, access, signal } = options; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new LangSmithValidationError( + `port must be between 1 and 65535 (got ${port})`, + "port", + ); + } + if ( + access !== undefined && + access !== "restricted" && + access !== "workspace" + ) { + throw new LangSmithValidationError( + `access must be "restricted" or "workspace" (got ${String(access)})`, + "access", + ); + } + const loginMode = access !== undefined; + if (expiresInSeconds !== undefined && loginMode) { + throw new LangSmithValidationError( + `expiresInSeconds does not apply to access "${access}": a LangSmith ` + + "login URL carries no token and does not expire", + "expiresInSeconds", + ); + } + + const url = this._boxUrl(name, "service-url"); + const payload: Record = { port }; + if (!loginMode && expiresInSeconds !== undefined) { + payload.expires_in_seconds = expiresInSeconds; + } + if (access !== undefined) { + payload.access = access; + } + + const response = await this._postJson(url, payload, { signal }); + const data = await response.json(); + if (loginMode) { + return new ServiceLoginUrl(data); + } + // Refresh with the token parameters only. Carrying options.signal over + // would tie every later refresh to the first request's lifetime, so a + // caller-supplied timeout signal would leave the URL unable to refresh. + return new ServiceUrl( + data, + () => + this.serviceUrl(name, { + port, + expiresInSeconds, + }) as Promise, + ); + } + + private async _postJson( + url: string, + body: Record, + options?: { signal?: AbortSignal }, + ): Promise { + const response = await this._fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: options?.signal, + }); + if (!response.ok) { + await handleClientHttpError(response); + } + return response; + } + + // ========================================================================= + // Sandbox Operations + // ========================================================================= + + /** + * Create a new Sandbox. + * + * Remember to call `sandbox.delete()` when done to clean up resources. + * + * @param snapshotId - Optional snapshot ID to boot from. + * @param options - Creation options. Use `options.snapshotName` to boot from + * a named snapshot instead of the default runtime. + * @returns Created Sandbox. + * @throws ResourceTimeoutError if timeout waiting for sandbox to be ready. + * @throws SandboxCreationError if sandbox creation fails. + * @throws LangSmithValidationError if TTL values are invalid, or if both + * `snapshotId` and `options.snapshotName` are provided. + * + * @example + * ```typescript + * const sandbox = await client.createSandbox(); + * // Or, resolve by snapshot name: + * const sandbox = await client.createSandbox({ + * snapshotName: "python", + * }); + * try { + * const result = await sandbox.run("echo hello"); + * console.log(result.stdout); + * } finally { + * await sandbox.delete(); + * } + * ``` + */ + async createSandbox(options?: CreateSandboxOptions): Promise; + async createSandbox( + snapshotId?: string, + options?: CreateSandboxOptions, + ): Promise; + async createSandbox( + snapshotIdOrOptions?: string | CreateSandboxOptions, + options: CreateSandboxOptions = {}, + ): Promise { + const snapshotId = + typeof snapshotIdOrOptions === "string" ? snapshotIdOrOptions : undefined; + const resolvedOptions = + typeof snapshotIdOrOptions === "object" && snapshotIdOrOptions !== null + ? snapshotIdOrOptions + : options; + const { + snapshotName, + name, + timeout = 30, + waitForReady = true, + idleTtlSeconds, + deleteAfterStopSeconds, + vCpus, + memBytes, + fsCapacityBytes, + mountConfig, + proxyConfig, + runConfig, + accessDelegation, + } = resolvedOptions; + + if (snapshotId && snapshotName) { + throw new LangSmithValidationError( + "At most one of snapshotId or options.snapshotName may be set", + "snapshotId", + ); + } + validateTtl(idleTtlSeconds, "idleTtlSeconds"); + validateTtl(deleteAfterStopSeconds, "deleteAfterStopSeconds"); + + const url = `${this._baseUrl}/boxes`; + + const payload: Record = { + wait_for_ready: waitForReady, + }; + if (snapshotId) { + payload.snapshot_id = snapshotId; + } + if (snapshotName) { + payload.snapshot_name = snapshotName; + } + if (waitForReady) { + payload.timeout = timeout; + } + if (name) { + payload.name = name; + } + if (idleTtlSeconds !== undefined) { + payload.idle_ttl_seconds = idleTtlSeconds; + } + if (deleteAfterStopSeconds !== undefined) { + payload.delete_after_stop_seconds = deleteAfterStopSeconds; + } + if (vCpus !== undefined) { + payload.vcpus = vCpus; + } + if (memBytes !== undefined) { + payload.mem_bytes = memBytes; + } + if (fsCapacityBytes !== undefined) { + payload.fs_capacity_bytes = fsCapacityBytes; + } + if (mountConfig !== undefined) { + validateMountConfigProxyConfig(mountConfig, proxyConfig); + payload.mount_config = mountConfig; + } + if (proxyConfig !== undefined) { + payload.proxy_config = proxyConfig; + } + if (runConfig !== undefined) { + payload.run_config = runConfig; + } + if (accessDelegation !== undefined) { + payload.access_delegation = validateAccessDelegation(accessDelegation); + } + + const httpTimeout = waitForReady ? (timeout + 30) * 1000 : 30 * 1000; + + const response = await this._fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(httpTimeout), + }); + + if (!response.ok) { + await handleSandboxCreationError(response); + } + + const data = (await response.json()) as SandboxData; + return new Sandbox(data, this); + } + + /** + * Get a Sandbox by name. + * + * The sandbox is NOT automatically deleted. Use deleteSandbox() for cleanup. + * + * @param name - Sandbox name. + * @returns Sandbox. + * @throws LangSmithResourceNotFoundError if sandbox not found. + */ + async getSandbox( + name: string, + options?: { signal?: AbortSignal }, + ): Promise { + const url = this._boxUrl(name); + + const response = await this._fetch(url, { signal: options?.signal }); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `Sandbox '${name}' not found`, + "sandbox", + ); + } + await handleClientHttpError(response); + } + + const data = (await response.json()) as SandboxData; + return new Sandbox(data, this); + } + + /** + * List all Sandboxes. + * + * @returns List of Sandboxes. + */ + async listSandboxes(): Promise { + const url = `${this._baseUrl}/boxes`; + + const response = await this._fetch(url); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithSandboxAPIError( + `API endpoint not found: ${url}. Check that apiEndpoint is correct.`, + ); + } + await handleClientHttpError(response); + } + + const data = await response.json(); + return ((data.sandboxes ?? []) as SandboxData[]).map( + (s) => new Sandbox(s, this), + ); + } + + /** + * Update a sandbox's display name. + * + * @param name - Current sandbox name. + * @param newName - New display name. + */ + async updateSandbox(name: string, newName: string): Promise; + /** + * Update a sandbox's name, retention settings (idle stop and + * delete-after-stop), and/or proxy config. + * + * @param name - Current sandbox name. + * @param options - Fields to update. Omit a field to leave it unchanged. + * @returns Updated Sandbox. If no fields are provided, returns the current sandbox. + * @throws LangSmithResourceNotFoundError if sandbox not found. + * @throws LangSmithResourceNameConflictError if newName is already in use. + * @throws LangSmithValidationError if retention values are invalid. + * @throws LangSmithSandboxNotReadyError if proxyConfig was given and the + * sandbox is not `ready`. + */ + async updateSandbox( + name: string, + options: UpdateSandboxOptions, + ): Promise; + async updateSandbox( + name: string, + newNameOrOptions: string | UpdateSandboxOptions, + ): Promise { + const options: UpdateSandboxOptions = + typeof newNameOrOptions === "string" + ? { newName: newNameOrOptions } + : newNameOrOptions; + + const { + newName, + idleTtlSeconds, + deleteAfterStopSeconds, + proxyConfig, + runConfig, + } = options; + validateTtl(idleTtlSeconds, "idleTtlSeconds"); + validateTtl(deleteAfterStopSeconds, "deleteAfterStopSeconds"); + + if ( + newName === undefined && + idleTtlSeconds === undefined && + deleteAfterStopSeconds === undefined && + proxyConfig === undefined && + runConfig === undefined + ) { + return this.getSandbox(name); + } + + const url = this._boxUrl(name); + const payload: Record = {}; + if (newName !== undefined) { + payload.name = newName; + } + if (idleTtlSeconds !== undefined) { + payload.idle_ttl_seconds = idleTtlSeconds; + } + if (deleteAfterStopSeconds !== undefined) { + payload.delete_after_stop_seconds = deleteAfterStopSeconds; + } + if (proxyConfig !== undefined) { + payload.proxy_config = proxyConfig; + } + if (runConfig !== undefined) { + payload.run_config = runConfig; + } + + const response = await this._fetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `Sandbox '${name}' not found`, + "sandbox", + ); + } + if (response.status === 409) { + throw new LangSmithResourceNameConflictError( + newName !== undefined + ? `Sandbox name '${newName}' already in use` + : "Sandbox update conflict (name may already be in use)", + "sandbox", + ); + } + if (proxyConfig !== undefined) { + await throwIfNotReady(response, name); + } + await handleClientHttpError(response); + } + + const data = (await response.json()) as SandboxData; + return new Sandbox(data, this); + } + + /** + * Delete a Sandbox. + * + * @param name - Sandbox name. + * @throws LangSmithResourceNotFoundError if sandbox not found. + */ + async deleteSandbox(name: string): Promise { + const url = this._boxUrl(name); + + const response = await this._fetch(url, { method: "DELETE" }); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `Sandbox '${name}' not found`, + "sandbox", + ); + } + await handleClientHttpError(response); + } + } + + /** + * Get the provisioning status of a sandbox. + * + * This is a lightweight endpoint designed for polling during async creation. + * Use this instead of getSandbox() when you only need the status. + * + * @param name - Sandbox name. + * @returns ResourceStatus with status and optional status_message. + * @throws LangSmithResourceNotFoundError if sandbox not found. + */ + async getSandboxStatus( + name: string, + options?: { signal?: AbortSignal }, + ): Promise { + const url = this._boxUrl(name, "status"); + + const response = await this._fetch(url, { signal: options?.signal }); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `Sandbox '${name}' not found`, + "sandbox", + ); + } + await handleClientHttpError(response); + } + + return (await response.json()) as ResourceStatus; + } + + /** + * Wait for a sandbox to become ready. + * + * Polls getSandboxStatus() until the sandbox reaches "ready" or "failed" status, + * then returns the full Sandbox object. + * + * @param name - Sandbox name. + * @param options - Polling options (timeout, pollInterval). + * @returns Ready Sandbox. + * @throws LangSmithResourceCreationError if sandbox status becomes "failed". + * @throws LangSmithResourceTimeoutError if timeout expires while still provisioning. + * @throws LangSmithResourceNotFoundError if sandbox not found. + * + * @example + * ```typescript + * const sandbox = await client.createSandbox(snapshot.id, { waitForReady: false }); + * // ... do other work ... + * const readySandbox = await client.waitForSandbox(sandbox.name); + * ``` + */ + async waitForSandbox( + name: string, + options: WaitForSandboxOptions = {}, + ): Promise { + const { timeout = 120, pollInterval = 1.0, signal } = options; + const deadline = Date.now() + timeout * 1000; + let lastStatus = "provisioning"; + + while (Date.now() < deadline) { + signal?.throwIfAborted(); + + const statusResult = await this.getSandboxStatus(name, { signal }); + lastStatus = statusResult.status; + + if (statusResult.status === "ready") { + return this.getSandbox(name, { signal }); + } + + if (statusResult.status === "failed") { + throw new LangSmithResourceCreationError( + statusResult.status_message ?? `Sandbox '${name}' creation failed`, + "sandbox", + ); + } + + // Wait before polling again, capped to remaining time + jitter + const remaining = deadline - Date.now(); + const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10% + const delay = Math.min(pollInterval * 1000 + jitter, remaining); + if (delay > 0) { + await sleepWithSignal(delay, signal); + } + } + + throw new LangSmithResourceTimeoutError( + `Sandbox '${name}' did not become ready within ${timeout}s`, + "sandbox", + lastStatus, + ); + } + + /** + * Start a stopped sandbox and wait until ready. + * + * @param name - Sandbox name. + * @param options - Options with timeout. + * @returns Sandbox in "ready" status. + */ + async startSandbox( + name: string, + options: StartSandboxOptions = {}, + ): Promise { + const { timeout = 120, signal } = options; + const url = this._boxUrl(name, "start"); + + await this._postJson(url, {}, { signal }); + return this.waitForSandbox(name, { timeout, signal }); + } + + /** + * Create a link that downloads one file from a sandbox. + * + * The link carries its own token, so anyone holding the URL can fetch that + * one file without a LangSmith credential. It is pinned to the sandbox and + * the exact path and cannot be repointed at another file. Fetching wakes a + * stopped sandbox. + * + * Do not modify the file after minting a link for it. The link is pinned to + * a path, not to a snapshot of the contents, so a later write to that path + * may or may not be reflected in what the link serves. Write a new file and + * mint a new link when the contents change. + * + * @param name - Sandbox name. + * @param path - File path inside the sandbox. + * @param options - Expiry and response header overrides. + * @returns The link and its expiry, if any. + * + * @example + * ```typescript + * const link = await client.generateDownloadURL("my-vm", "/tmp/report.pdf"); + * console.log(link.download_url); + * ``` + */ + async generateDownloadURL( + name: string, + path: string, + options: GenerateDownloadURLOptions = {}, + ): Promise { + const { expiresInSeconds, contentType, contentDisposition, signal } = + options; + if (expiresInSeconds !== undefined && expiresInSeconds < 1) { + throw new LangSmithValidationError( + `expiresInSeconds must be greater than 0 (got ${expiresInSeconds}); ` + + `omit it for a link that never expires`, + ); + } + + const url = this._boxUrl(name, "download-url"); + const payload: Record = { path }; + if (expiresInSeconds !== undefined) { + payload.expires_in_seconds = expiresInSeconds; + } + if (contentType !== undefined) { + payload.content_type = contentType; + } + if (contentDisposition !== undefined) { + payload.content_disposition = contentDisposition; + } + + const response = await this._postJson(url, payload, { signal }); + return (await response.json()) as DownloadURL; + } + + /** + * Stop a running sandbox (preserves sandbox files for later restart). + * + * @param name - Sandbox name. + */ + async stopSandbox(name: string): Promise { + const url = this._boxUrl(name, "stop"); + await this._postJson(url, {}); + } + + // ========================================================================= + // Snapshot Operations + // ========================================================================= + + /** + * Build a snapshot from a Docker image. + * + * Blocks until the snapshot is ready (polls with 2s interval). + * + * @param name - Snapshot name. + * @param dockerImage - Docker image to build from (e.g., "python:3.12-slim"). + * @param fsCapacityBytes - Filesystem capacity in bytes. + * @param options - Additional options (registry ID, timeout). + * @returns Snapshot in "ready" status. + */ + async createSnapshot( + name: string, + dockerImage: string, + fsCapacityBytes: number, + options: CreateSnapshotOptions = {}, + ): Promise { + const { registryId, runConfig, timeout = 60, signal } = options; + const url = `${this._baseUrl}/snapshots`; + + const payload: Record = { + name, + docker_image: dockerImage, + fs_capacity_bytes: fsCapacityBytes, + }; + if (registryId !== undefined) { + payload.registry_id = registryId; + } + if (runConfig !== undefined) { + payload.run_config = runConfig; + } + + const response = await this._postJson(url, payload, { signal }); + const snapshot = (await response.json()) as Snapshot; + return this.waitForSnapshot(snapshot.id, { timeout, signal }); + } + + /** + * Build a snapshot from a local Dockerfile context. + * + * Creates a temporary builder sandbox, uploads the Docker build context, + * runs BuildKit inside the sandbox, and captures the built image as a + * LangSmith snapshot. + * + * @param name - Snapshot name. + * @param dockerfile - Local Dockerfile path, relative to context by default. + * @param fsCapacityBytesOrOptions - Optional filesystem capacity in bytes, or + * build options. When omitted, the server applies its default capacity. + * @param options - Build options when capacity is passed separately. + * @returns Snapshot in "ready" status. + */ + async createSnapshotFromDockerfile( + name: string, + dockerfile: string, + fsCapacityBytesOrOptions?: number | CreateDockerfileSnapshotOptions, + options: CreateDockerfileSnapshotOptions = {}, + ): Promise { + const fsCapacityBytes = + typeof fsCapacityBytesOrOptions === "number" + ? fsCapacityBytesOrOptions + : undefined; + const resolvedOptions = + typeof fsCapacityBytesOrOptions === "number" + ? options + : fsCapacityBytesOrOptions ?? options; + const { + context = ".", + buildArgs, + target, + onBuildLog, + vCpus, + memBytes, + timeout = 60, + } = resolvedOptions; + const { contextPath, dockerfileRel } = await resolveDockerfileContext( + dockerfile, + context, + ); + + const builderName = `snapshot-builder-${uuidv4() + .replace(/-/g, "") + .slice(0, 12)}`; + // Stage the build on the capacity-backed root filesystem, not /tmp. + // Inside the sandbox /tmp is a RAM-backed tmpfs that fsCapacityBytes does + // not size, and BuildKit's native snapshotter writes a full copy of every + // layer under its root, so a /tmp build exhausts guest RAM and fails with + // "No space left on device". + const buildRoot = `/var/lib/langsmith-build/${uuidv4() + .replace(/-/g, "") + .slice(0, 12)}`; + const remoteContext = `${buildRoot}/context`; + const remoteTar = `${buildRoot}/context.tar`; + const imageRef = `langsmith-snapshot-build:${uuidv4().replace(/-/g, "")}`; + const buildkitRoot = `${buildRoot}/buildkit-root`; + const buildkitRun = `${buildRoot}/buildkit-run`; + + const builder = await this.createSandbox({ + name: builderName, + timeout, + vCpus, + memBytes, + fsCapacityBytes, + }); + try { + await builder.write( + remoteTar, + await makeDockerContextTar(contextPath), + timeout, + ); + await builder.run( + [ + `rm -rf ${shellQuote(remoteContext)}`, + `mkdir -p ${shellQuote(remoteContext)}`, + `tar -xf ${shellQuote(remoteTar)} -C ${shellQuote(remoteContext)}`, + ].join(" && "), + { timeout }, + ); + + const result = await builder.run( + makeDockerfileBuildCommand({ + remoteContext, + dockerfileRel, + imageRef, + buildkitRoot, + buildkitRun, + buildArgs, + target, + }), + { + timeout, + onStdout: onBuildLog, + onStderr: onBuildLog, + }, + ); + if (result.exit_code !== 0) { + throw new LangSmithResourceCreationError( + "Dockerfile snapshot build failed", + "snapshot", + ); + } + return await this.captureSnapshot(builder.name, name, { + dockerImage: imageRef, + fsCapacityBytes, + timeout, + }); + } finally { + await builder.delete(); + } + } + + /** + * Capture a snapshot from a running sandbox. + * + * Blocks until the snapshot is ready (polls with 2s interval). + * + * @param sandboxName - Name of the sandbox to capture from. + * @param name - Snapshot name. + * @param options - Capture options (timeout). + * @returns Snapshot in "ready" status. + */ + async captureSnapshot( + sandboxName: string, + name: string, + options: CaptureSnapshotOptions = {}, + ): Promise { + const { + dockerImage, + fsCapacityBytes, + runConfig, + timeout = 60, + signal, + } = options; + const url = this._boxUrl(sandboxName, "snapshot"); + + const payload: Record = { name }; + if (dockerImage !== undefined) { + payload.docker_image = dockerImage; + } + if (fsCapacityBytes !== undefined) { + payload.fs_capacity_bytes = fsCapacityBytes; + } + if (runConfig !== undefined) { + payload.run_config = runConfig; + } + + const response = await this._postJson(url, payload, { signal }); + const snapshot = (await response.json()) as Snapshot; + return this.waitForSnapshot(snapshot.id, { timeout, signal }); + } + + /** + * Get a snapshot by ID. + * + * @param snapshotId - Snapshot UUID. + * @returns Snapshot. + */ + async getSnapshot( + snapshotId: string, + options?: { signal?: AbortSignal }, + ): Promise { + const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`; + + const response = await this._fetch(url, { signal: options?.signal }); + + if (!response.ok) { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `Snapshot '${snapshotId}' not found`, + "snapshot", + ); + } + await handleClientHttpError(response); + } + + return (await response.json()) as Snapshot; + } + + /** + * List snapshots, optionally filtered and paginated server-side. + * + * The backend always paginates this endpoint. When `limit` is omitted the + * server applies a default page size (currently 50), so a single call is + * not guaranteed to return every snapshot visible to the tenant. To iterate + * through all results, repeat the call with increasing `offset` values (or + * an explicit `limit`) until fewer than `limit` snapshots come back. + * + * @param options - Optional filter/pagination options. + * - `nameContains`: case-insensitive substring match on snapshot name. + * - `limit`: page size; must be between 1 and 500 (inclusive). Defaults + * to 50 server-side when omitted. + * - `offset`: number of snapshots to skip; must be `>= 0`. + * + * Values outside those ranges are rejected by the server. + * @returns A single page of Snapshots matching the provided filters. + * + * @example + * ```typescript + * const firstPage = await client.listSnapshots(); + * const page = await client.listSnapshots({ + * nameContains: "python", + * limit: 100, + * offset: 0, + * }); + * ``` + */ + async listSnapshots(options: ListSnapshotsOptions = {}): Promise { + const { nameContains, limit, offset, signal } = options; + + const params = new URLSearchParams(); + if (nameContains !== undefined) { + params.set("name_contains", nameContains); + } + if (limit !== undefined) { + params.set("limit", String(limit)); + } + if (offset !== undefined) { + params.set("offset", String(offset)); + } + + const query = params.toString(); + const url = query + ? `${this._baseUrl}/snapshots?${query}` + : `${this._baseUrl}/snapshots`; + + const response = await this._fetch(url, { signal }); + + if (!response.ok) { + await handleClientHttpError(response); + } + + const data = await response.json(); + return (data.snapshots ?? []) as Snapshot[]; + } + + /** + * Delete a snapshot. + * + * @param snapshotId - Snapshot UUID. + */ + async deleteSnapshot(snapshotId: string): Promise { + const url = `${this._baseUrl}/snapshots/${encodeURIComponent(snapshotId)}`; + + const response = await this._fetch(url, { method: "DELETE" }); + + if (!response.ok) { + await handleClientHttpError(response); + } + } + + /** + * Poll until a snapshot reaches "ready" or "failed" status. + * + * @param snapshotId - Snapshot UUID. + * @param options - Polling options (timeout, pollInterval). + * @returns Snapshot in "ready" status. + */ + async waitForSnapshot( + snapshotId: string, + options: WaitForSnapshotOptions = {}, + ): Promise { + const { timeout = 300, pollInterval = 2.0, signal } = options; + const deadline = Date.now() + timeout * 1000; + let lastStatus = "building"; + + while (Date.now() < deadline) { + signal?.throwIfAborted(); + + const snapshot = await this.getSnapshot(snapshotId, { signal }); + lastStatus = snapshot.status; + + if (snapshot.status === "ready") { + return snapshot; + } + + if (snapshot.status === "failed") { + throw new LangSmithResourceCreationError( + snapshot.status_message ?? `Snapshot '${snapshotId}' build failed`, + "snapshot", + ); + } + + // Cap sleep to remaining time + jitter + const remaining = deadline - Date.now(); + const jitter = pollInterval * 200 * (Math.random() - 0.5); // ±10% + const delay = Math.min(pollInterval * 1000 + jitter, remaining); + if (delay > 0) { + await sleepWithSignal(delay, signal); + } + } + + throw new LangSmithResourceTimeoutError( + `Snapshot '${snapshotId}' did not become ready within ${timeout}s`, + "snapshot", + lastStatus, + ); + } + + /** + * Returns a string representation of the SandboxClient instance. + * This method is called when the object is converted to a string + * or logged, ensuring sensitive information like API keys is not exposed. + * + * @returns A string representation of the SandboxClient. + */ + public toString(): string { + return `[LangSmithSandboxClient apiEndpoint=${JSON.stringify( + this._baseUrl, + )}]`; + } + + /** + * Custom inspect method for Node.js. + * This method is called when the object is inspected in the Node.js REPL + * or with console.log, ensuring sensitive information like API keys is not exposed. + * + * @returns A string representation of the SandboxClient for inspection. + */ + public [Symbol.for("nodejs.util.inspect.custom")](): string { + return this.toString(); + } +} diff --git a/src/lib/sandbox/command_handle.ts b/src/lib/sandbox/command_handle.ts new file mode 100644 index 0000000..cc4fe93 --- /dev/null +++ b/src/lib/sandbox/command_handle.ts @@ -0,0 +1,364 @@ +/** + * CommandHandle - async handle to a running command with streaming output + * and auto-reconnect. + * + * Port of Python's AsyncCommandHandle to TypeScript. + */ + +import type { ExecutionResult, OutputChunk, WsMessage } from "./types.js"; +import type { WSStreamControl } from "./ws_execute.js"; +import type { Sandbox } from "./sandbox.js"; +import { + LangSmithSandboxConnectionError, + LangSmithSandboxServerReloadError, + LangSmithSandboxOperationError, + LangSmithStreamEndedBeforeStartedError, +} from "./errors.js"; + +/** + * Whether a 'started' frame proves *this* command was reattached to. + * + * A command and its WebSocket are separate things: the command keeps running on + * the server and the socket is only this client's attachment to it. The server + * acknowledges every successful reattachment with 'started', which for a command + * that produces no output is the only evidence it landed. One naming a different + * command is not evidence, and must not clear the reconnect budget. + */ +function acknowledgesReconnect( + msg: WsMessage, + commandId: string | null, +): boolean { + const acked = msg.command_id; + if (acked === commandId) return true; + console.warn( + `Ignoring reconnect acknowledgement for command ${String( + acked, + )} while attached to ${String(commandId)}`, + ); + return false; +} + +/** + * Async handle to a running command with streaming output and auto-reconnect. + * + * Async iterable, yielding OutputChunk objects (stdout and stderr interleaved + * in arrival order). Access .result after iteration to get the full + * ExecutionResult. + * + * Auto-reconnect behavior: + * - Server hot-reload (1001 Going Away): reconnect immediately + * - Network error / unexpected close: reconnect with exponential backoff + * - User called kill(): do NOT reconnect (propagate error) + * + * @example + * ```typescript + * const handle = await sandbox.run("make build", { timeout: 600, wait: false }); + * + * for await (const chunk of handle) { // auto-reconnects on transient errors + * process.stdout.write(chunk.data); + * } + * + * const result = await handle.result; + * console.log(`Exit code: ${result.exit_code}`); + * ``` + */ +export class CommandHandle { + static MAX_AUTO_RECONNECTS = 5; + static BACKOFF_BASE = 0.5; // seconds + static BACKOFF_MAX = 8.0; // seconds + + private _stream: AsyncIterableIterator; + private _control: WSStreamControl | null; + private _sandbox: Sandbox; + private _commandId: string | null = null; + private _pid: number | null = null; + private _result: ExecutionResult | null = null; + private _stdoutParts: string[] = []; + private _stderrParts: string[] = []; + private _exhausted = false; + private _lastStdoutOffset: number; + private _lastStderrOffset: number; + private _started: boolean; + private _reconnectAttempts = 0; + private _onStdout?: (data: string) => void; + private _onStderr?: (data: string) => void; + private _stdinClosed: boolean; + private _pty: boolean; + + /** @internal */ + constructor( + messageStream: AsyncIterableIterator, + control: WSStreamControl | null, + sandbox: Sandbox, + options?: { + commandId?: string; + stdoutOffset?: number; + stderrOffset?: number; + onStdout?: (data: string) => void; + onStderr?: (data: string) => void; + stdinClosed?: boolean; + pty?: boolean; + }, + ) { + this._stream = messageStream; + this._control = control; + this._sandbox = sandbox; + this._lastStdoutOffset = options?.stdoutOffset ?? 0; + this._lastStderrOffset = options?.stderrOffset ?? 0; + this._onStdout = options?.onStdout; + this._onStderr = options?.onStderr; + this._stdinClosed = options?.stdinClosed ?? false; + this._pty = options?.pty ?? false; + + // New executions (no commandId): _ensureStarted reads "started". + // Reconnections (commandId set): skip since reconnect streams + // don't send a "started" message. + if (options?.commandId) { + this._commandId = options.commandId; + this._started = true; + } else { + this._started = false; + } + } + + /** + * Read the 'started' message to populate commandId and pid. + * + * Must be called (and awaited) before iterating for new executions. + */ + async _ensureStarted(): Promise { + if (this._started) return; + + const firstResult = await this._stream.next(); + if (firstResult.done) { + throw new LangSmithStreamEndedBeforeStartedError( + "Command stream ended before 'started' message", + ); + } + const firstMsg = firstResult.value; + if (firstMsg.type !== "started") { + throw new LangSmithSandboxOperationError( + `Expected 'started' message, got '${firstMsg.type}'`, + "command", + ); + } + this._commandId = (firstMsg.command_id as string) ?? null; + this._pid = (firstMsg.pid as number) ?? null; + this._started = true; + } + + /** The server-assigned command ID. Available after _ensureStarted(). */ + get commandId(): string | null { + return this._commandId; + } + + /** The process ID on the sandbox. Available after _ensureStarted(). */ + get pid(): number | null { + return this._pid; + } + + /** + * The final execution result. Drains the stream if not already exhausted. + */ + get result(): Promise { + return this._getResult(); + } + + private async _getResult(): Promise { + if (this._result === null) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of this) { + // drain + } + } + if (this._result === null) { + throw new LangSmithSandboxOperationError( + "Command stream ended without exit message", + "command", + ); + } + return this._result; + } + + /** + * Iterate over output chunks from the current stream (no reconnect). + */ + private async *_iterStream(): AsyncIterableIterator { + await this._ensureStarted(); + if (this._exhausted) return; + + for await (const msg of this._stream) { + const msgType = msg.type; + if (msgType === "started") { + if (acknowledgesReconnect(msg, this._commandId)) { + this._reconnectAttempts = 0; + } + } else if (msgType === "stdout" || msgType === "stderr") { + const chunk: OutputChunk = { + stream: msgType, + data: msg.data as string, + offset: (msg.offset as number) ?? 0, + }; + if (msgType === "stdout") { + this._stdoutParts.push(msg.data as string); + } else { + this._stderrParts.push(msg.data as string); + } + yield chunk; + } else if (msgType === "exit") { + this._result = { + stdout: this._stdoutParts.join(""), + stderr: this._stderrParts.join(""), + exit_code: (msg.exit_code as number) ?? -1, + }; + this._exhausted = true; + return; + } + } + throw new LangSmithSandboxConnectionError( + "Command stream ended without exit message", + ); + } + + /** + * Async iterate over output chunks with auto-reconnect on transient errors. + * + * Reconnect strategy: + * - 1001 Going Away (hot-reload): immediate reconnect, no delay + * - Other SandboxConnectionError: exponential backoff (0.5s, 1s, 2s...) + * - After kill(): no reconnect, error propagates + */ + async *[Symbol.asyncIterator](): AsyncIterableIterator { + this._reconnectAttempts = 0; + + while (true) { + try { + for await (const chunk of this._iterStream()) { + this._reconnectAttempts = 0; // Reset on successful data + if (chunk.stream === "stdout") { + this._lastStdoutOffset = + chunk.offset + new TextEncoder().encode(chunk.data).length; + this._onStdout?.(chunk.data); + } else { + this._lastStderrOffset = + chunk.offset + new TextEncoder().encode(chunk.data).length; + this._onStderr?.(chunk.data); + } + yield chunk; + } + return; // Stream ended normally (exit message received) + } catch (e) { + // instanceof, not a name check: subclasses (connect timeout, server + // reload) must stay retryable here. + if (!(e instanceof LangSmithSandboxConnectionError)) { + throw e; + } + + if (this._control && this._control.killed) { + throw e; + } + + this._reconnectAttempts++; + if (this._reconnectAttempts > CommandHandle.MAX_AUTO_RECONNECTS) { + throw new LangSmithSandboxConnectionError( + `Failed to reattach to the command ${this._reconnectAttempts} times in succession, giving up`, + ); + } + + const isHotReload = e instanceof LangSmithSandboxServerReloadError; + if (!isHotReload) { + const delay = Math.min( + CommandHandle.BACKOFF_BASE * 2 ** (this._reconnectAttempts - 1), + CommandHandle.BACKOFF_MAX, + ); + await new Promise((r) => setTimeout(r, delay * 1000)); + } + + if (this._commandId === null) { + throw e; + } + + const newHandle = await this._sandbox.reconnect(this._commandId, { + stdoutOffset: this._lastStdoutOffset, + stderrOffset: this._lastStderrOffset, + }); + this._stream = newHandle._stream; + this._control = newHandle._control; + this._exhausted = false; + } + } + } + + /** + * Send a kill signal to the running command (SIGKILL). + * + * The server kills the entire process group. The stream will + * subsequently yield an exit message with a non-zero exit code. + */ + kill(): void { + if (this._control) { + this._control.sendKill(); + } + } + + /** + * Write data to the command's stdin. + */ + sendInput(data: string): void { + if (this._stdinClosed) { + throw new LangSmithSandboxOperationError( + "stdin is closed for this command. Non-PTY commands close stdin by " + + "default so a command that reads it sees EOF instead of hanging; " + + "pass closeInput: false to run() to stream input into it.", + "sendInput", + ); + } + if (this._control) { + this._control.sendInput(data); + } + } + + /** + * Half-close stdin so the command reads EOF. + * + * Idempotent, and a no-op under a PTY, where input and output share one + * terminal file descriptor and there is no write end to close -- send an + * EOT byte (0x04) with {@link sendInput} instead. + */ + closeInput(): void { + if (this._pty || this._stdinClosed) return; + this._stdinClosed = true; + this._control?.sendCloseStdin(); + } + + /** Last known stdout byte offset (for manual reconnection). */ + get lastStdoutOffset(): number { + return this._lastStdoutOffset; + } + + /** Last known stderr byte offset (for manual reconnection). */ + get lastStderrOffset(): number { + return this._lastStderrOffset; + } + + /** + * Reconnect to this command from the last known offsets. + * + * Returns a new CommandHandle that resumes output from where this one + * left off. + */ + async reconnect(): Promise { + if (this._commandId === null) { + throw new LangSmithSandboxOperationError( + "Cannot reconnect: command ID not available", + "reconnect", + ); + } + return this._sandbox.reconnect(this._commandId, { + stdoutOffset: this._lastStdoutOffset, + stderrOffset: this._lastStderrOffset, + stdinClosed: this._stdinClosed, + pty: this._pty, + }); + } +} diff --git a/src/lib/sandbox/errors.ts b/src/lib/sandbox/errors.ts new file mode 100644 index 0000000..9bf2e0d --- /dev/null +++ b/src/lib/sandbox/errors.ts @@ -0,0 +1,368 @@ +/** + * Custom error classes for the sandbox module. + * + * All sandbox errors extend LangSmithSandboxError for unified error handling. + * The errors are organized by type rather than resource type, with additional + * properties for specific handling when needed. + */ + +/** + * Base exception for sandbox client errors. + */ +export class LangSmithSandboxError extends Error { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxError"; + } +} + +// ============================================================================= +// Connection and Authentication Errors +// ============================================================================= + +/** + * Raised when the API endpoint returns an unexpected error. + * + * For example, this is raised for wrong URL or path. + */ +export class LangSmithSandboxAPIError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxAPIError"; + } +} + +/** + * Raised when authentication fails (invalid or missing API key). + */ +export class LangSmithSandboxAuthenticationError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxAuthenticationError"; + } +} + +/** + * Raised when connection to the sandbox server fails. + */ +export class LangSmithSandboxConnectionError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxConnectionError"; + } +} + +/** + * Raised when a transient connection failure occurs before a command starts. + * + * `run()` retries this error with the same command ID so the server can + * deduplicate an attempt whose outcome is unknown. + */ +export class LangSmithSandboxRetryableConnectionError extends LangSmithSandboxConnectionError { + readonly retryAfterSeconds?: number; + + constructor(message: string, retryAfterSeconds?: number) { + super(message); + this.name = "LangSmithSandboxRetryableConnectionError"; + this.retryAfterSeconds = retryAfterSeconds; + } +} + +// ============================================================================= +// Resource Errors (type-based, with resourceType property) +// ============================================================================= + +/** + * Raised when a resource is not found. + */ +export class LangSmithResourceNotFoundError extends LangSmithSandboxError { + resourceType?: string; + + constructor(message: string, resourceType?: string) { + super(message); + this.name = "LangSmithResourceNotFoundError"; + this.resourceType = resourceType; + } +} + +/** + * Raised when an operation times out. + */ +export class LangSmithResourceTimeoutError extends LangSmithSandboxError { + resourceType?: string; + lastStatus?: string; + + constructor(message: string, resourceType?: string, lastStatus?: string) { + super(message); + this.name = "LangSmithResourceTimeoutError"; + this.resourceType = resourceType; + this.lastStatus = lastStatus; + } + + override toString(): string { + const base = super.toString(); + if (this.lastStatus) { + return `${base} (last_status: ${this.lastStatus})`; + } + return base; + } +} + +/** + * Raised when deleting a resource that is still in use. + */ +export class LangSmithResourceInUseError extends LangSmithSandboxError { + resourceType?: string; + + constructor(message: string, resourceType?: string) { + super(message); + this.name = "LangSmithResourceInUseError"; + this.resourceType = resourceType; + } +} + +/** + * Raised when creating a resource that already exists. + */ +export class LangSmithResourceAlreadyExistsError extends LangSmithSandboxError { + resourceType?: string; + + constructor(message: string, resourceType?: string) { + super(message); + this.name = "LangSmithResourceAlreadyExistsError"; + this.resourceType = resourceType; + } +} + +/** + * Raised when updating a resource name to one that already exists. + */ +export class LangSmithResourceNameConflictError extends LangSmithSandboxError { + resourceType?: string; + + constructor(message: string, resourceType?: string) { + super(message); + this.name = "LangSmithResourceNameConflictError"; + this.resourceType = resourceType; + } +} + +// ============================================================================= +// Validation and Quota Errors +// ============================================================================= + +/** + * Raised when request validation fails. + * + * This includes: + * - Resource values exceeding server-defined limits (CPU, memory, storage) + * - Invalid resource units + * - Invalid name formats + */ +export class LangSmithValidationError extends LangSmithSandboxError { + field?: string; + details?: Array>; + errorType?: string; + + constructor( + message: string, + field?: string, + details?: Array>, + errorType?: string, + ) { + super(message); + this.name = "LangSmithValidationError"; + this.field = field; + this.details = details; + this.errorType = errorType; + } +} + +/** + * Raised when organization quota limits are exceeded. + * + * Users should contact technical support via our Support Portal + * (https://support.langchain.com) to increase quotas. + */ +export class LangSmithQuotaExceededError extends LangSmithSandboxError { + quotaType?: string; + + constructor(message: string, quotaType?: string) { + super(message); + this.name = "LangSmithQuotaExceededError"; + this.quotaType = quotaType; + } +} + +// ============================================================================= +// Resource Creation Errors +// ============================================================================= + +/** + * Raised when resource provisioning fails (general-purpose). + */ +export class LangSmithResourceCreationError extends LangSmithSandboxError { + /** + * Type of resource that failed (e.g., "sandbox", "snapshot"). + */ + resourceType?: string; + /** + * Machine-readable error type (ImagePull, CrashLoop, SandboxConfig, Unschedulable). + */ + errorType?: string; + + constructor(message: string, resourceType?: string, errorType?: string) { + super(message); + this.name = "LangSmithResourceCreationError"; + this.resourceType = resourceType; + this.errorType = errorType; + } + + override toString(): string { + if (this.errorType) { + return `${super.toString()} [${this.errorType}]`; + } + return super.toString(); + } +} + +/** + * Raised when sandbox creation fails. + */ +export class LangSmithSandboxCreationError extends LangSmithSandboxError { + /** + * Machine-readable error type (ImagePull, CrashLoop, SandboxConfig, Unschedulable). + */ + errorType?: string; + + constructor(message: string, errorType?: string) { + super(message); + this.name = "LangSmithSandboxCreationError"; + this.errorType = errorType; + } + + override toString(): string { + if (this.errorType) { + return `${super.toString()} [${this.errorType}]`; + } + return super.toString(); + } +} + +// ============================================================================= +// Sandbox Operation Errors (runtime errors during sandbox interaction) +// ============================================================================= + +/** + * Raised when dataplane_url is not available for the sandbox. + * + * This occurs when the sandbox-router URL is not configured for the cluster. + */ +export class LangSmithDataplaneNotConfiguredError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithDataplaneNotConfiguredError"; + } +} + +/** + * Raised when attempting to interact with a sandbox that is not ready. + */ +export class LangSmithSandboxNotReadyError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxNotReadyError"; + } +} + +/** + * Raised when a sandbox operation fails (run, read, write). + */ +export class LangSmithSandboxOperationError extends LangSmithSandboxError { + /** + * The operation that failed (command, read, write). + */ + operation?: string; + /** + * Machine-readable error type from the API. + */ + errorType?: string; + + constructor(message: string, operation?: string, errorType?: string) { + super(message); + this.name = "LangSmithSandboxOperationError"; + this.operation = operation; + this.errorType = errorType; + } + + override toString(): string { + if (this.errorType) { + return `${super.toString()} [${this.errorType}]`; + } + return super.toString(); + } +} + +/** + * Raised when a command exceeds its timeout. + */ +export class LangSmithCommandTimeoutError extends LangSmithSandboxOperationError { + timeout?: number; + + constructor(message: string, timeout?: number) { + super(message, "command", "CommandTimeout"); + this.name = "LangSmithCommandTimeoutError"; + this.timeout = timeout; + } +} + +/** + * Internal: a command WebSocket closed before the guest sent its 'started' + * frame (the proxied tunnel was torn down gracefully mid-handshake). + * + * Marks the idempotently-retryable early close, as distinct from a + * command-level failure. Subclasses the operation error so callers that catch + * the public type are unaffected. + */ +export class LangSmithStreamEndedBeforeStartedError extends LangSmithSandboxOperationError { + constructor(message: string) { + super(message, "command"); + this.name = "LangSmithStreamEndedBeforeStartedError"; + } +} + +/** + * Thrown when the socket fails or times out before the WebSocket handshake. + * + * The execute frame was never sent, so re-issuing the same command ID cannot + * double-run a command. + */ +export class LangSmithSandboxConnectTimeoutError extends LangSmithSandboxRetryableConnectionError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxConnectTimeoutError"; + } +} + +/** + * Raised when the sandbox server is reloading (close code 1001). + * + * Subclass of connection error that signals immediate reconnect (no backoff). + */ +export class LangSmithSandboxServerReloadError extends LangSmithSandboxConnectionError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxServerReloadError"; + } +} + +/** + * Raised when a sandbox user token or proxy callback signature fails + * verification. + */ +export class LangSmithSandboxTokenVerificationError extends LangSmithSandboxError { + constructor(message: string) { + super(message); + this.name = "LangSmithSandboxTokenVerificationError"; + } +} diff --git a/src/lib/sandbox/helpers.ts b/src/lib/sandbox/helpers.ts new file mode 100644 index 0000000..9448ca7 --- /dev/null +++ b/src/lib/sandbox/helpers.ts @@ -0,0 +1,506 @@ +/** + * Shared helper functions for error handling. + * + * These functions are used to parse error responses and raise appropriate + * exceptions. They contain no I/O operations. + */ + +import type { + FileChunk, + FileStat, + ReadRangeOptions, + RunConfig, +} from "./types.js"; +import { + LangSmithQuotaExceededError, + LangSmithResourceNotFoundError, + LangSmithResourceTimeoutError, + LangSmithSandboxAPIError, + LangSmithSandboxAuthenticationError, + LangSmithSandboxError, + LangSmithSandboxConnectionError, + LangSmithSandboxCreationError, + LangSmithSandboxNotReadyError, + LangSmithSandboxOperationError, + LangSmithValidationError, +} from "./errors.js"; + +// ============================================================================= +// Input validation +// ============================================================================= + +/** + * Validate TTL values for sandbox create/update (minute resolution). + * + * @param value - TTL in seconds (`undefined` means unset; `0` disables). + * @param name - Parameter name for error messages. + * @throws LangSmithValidationError if negative or not a multiple of 60 (when non-zero). + */ +export function validateTtl(value: number | undefined, name: string): void { + if (value === undefined) { + return; + } + if (value < 0) { + throw new LangSmithValidationError( + `${name} must be >= 0, got ${value}`, + name, + ); + } + if (value !== 0 && value % 60 !== 0) { + throw new LangSmithValidationError( + `${name} must be a multiple of 60 seconds, got ${value}`, + name, + ); + } +} + +// ============================================================================= +// Error Response Parsing +// ============================================================================= + +interface ParsedError { + errorType?: string; + message: string; +} + +interface ValidationDetail { + loc?: unknown[]; + msg?: string; + type?: string; + [key: string]: unknown; +} + +/** + * Parse standardized error response. + * + * Expected format: {"detail": {"error": "...", "message": "..."}} + */ +export async function parseErrorResponse( + response: Response, +): Promise { + try { + const data = await response.json(); + const detail = data?.detail; + + // Standardized format: {"detail": {"error": "...", "message": "..."}} + if (detail && typeof detail === "object" && !Array.isArray(detail)) { + const message = + detail.message || `HTTP ${response.status}: ${response.statusText}`; + return { + errorType: detail.error, + message: + typeof detail.error_id === "string" && detail.error_id + ? `${message} (error_id=${detail.error_id})` + : message, + }; + } + + // Pydantic validation error format: {"detail": [{"loc": [...], "msg": "..."}]} + if (Array.isArray(detail) && detail.length > 0) { + const messages = detail + .filter( + (d): d is ValidationDetail => typeof d === "object" && d !== null, + ) + .map((d) => d.msg || String(d)) + .filter(Boolean); + return { + errorType: undefined, + message: + messages.length > 0 + ? messages.join("; ") + : `HTTP ${response.status}: ${response.statusText}`, + }; + } + + // Fallback for plain string detail + return { + errorType: undefined, + message: detail || `HTTP ${response.status}: ${response.statusText}`, + }; + } catch { + return { + errorType: undefined, + message: `HTTP ${response.status}: ${response.statusText}`, + }; + } +} + +/** + * Parse Pydantic validation error response. + * + * Returns a list of validation error details. + */ +export async function parseValidationError( + response: Response, +): Promise { + try { + const data = await response.json(); + const detail = data?.detail; + if (Array.isArray(detail)) { + return detail; + } + return []; + } catch { + return []; + } +} + +/** + * Extract quota type from error message. + */ +export function extractQuotaType(message: string): string | undefined { + const messageLower = message.toLowerCase(); + + // Check for sandbox count quota + if ( + messageLower.includes("sandbox") && + (messageLower.includes("count") || messageLower.includes("limit")) + ) { + return "sandbox_count"; + } else if (messageLower.includes("cpu")) { + return "cpu"; + } else if (messageLower.includes("memory")) { + return "memory"; + } else if (messageLower.includes("storage")) { + return "storage"; + } + return undefined; +} + +// ============================================================================= +// Client Error Handlers +// ============================================================================= + +/** + * Handle HTTP errors specific to sandbox creation. + * + * Maps API error responses to specific exception types: + * - 408: LangSmithResourceTimeoutError (sandbox didn't become ready in time) + * - 422: LangSmithValidationError (bad input) or LangSmithSandboxCreationError (runtime) + * - 429: LangSmithQuotaExceededError (org limits exceeded) + * - 503: LangSmithSandboxCreationError (no resources available) + * - Other: Falls through to generic error handling + */ +export async function handleSandboxCreationError( + response: Response, +): Promise { + const status = response.status; + const clonedResponse = response.clone(); + const data = await parseErrorResponse(response); + + if (status === 408) { + // Timeout - include the message which contains last known status + throw new LangSmithResourceTimeoutError(data.message, "sandbox"); + } else if (status === 422) { + // Check if this is a Pydantic validation error (bad input) vs creation error + const details = await parseValidationError(clonedResponse); + if (details.length > 0 && details.some((d) => d.type === "value_error")) { + // Pydantic validation error (bad input - exceeds server limits) + const field = details[0]?.loc?.slice(-1)[0] as string | undefined; + throw new LangSmithValidationError(data.message, field, details); + } else { + // Sandbox creation failed (runtime error like image pull failure) + throw new LangSmithSandboxCreationError(data.message, data.errorType); + } + } else if (status === 429) { + // Organization quota exceeded - extract type or default to sandbox_count + const quotaType = extractQuotaType(data.message) ?? "unknown"; + throw new LangSmithQuotaExceededError(data.message, quotaType); + } else if (status === 503) { + // Service Unavailable - scheduling failed + throw new LangSmithSandboxCreationError( + data.message, + data.errorType || "Unschedulable", + ); + } + // Fall through to generic handling — pass clone since body is already consumed + return handleClientHttpError(clonedResponse); +} + +/** + * Throw `LangSmithSandboxNotReadyError` when the API rejected a proxy-config + * update because the sandbox is not `ready`. + * + * That status check is the sole source of `InvalidRequest` on the update + * endpoint for the fields this client sends, so a typed error lets callers + * start the sandbox and retry instead of matching on status codes. Reads a + * clone so the caller's own error handling can still consume the body. + */ +export async function throwIfNotReady( + response: Response, + name: string, +): Promise { + if (response.status !== 400) { + return; + } + const data = await parseErrorResponse(response.clone()); + if (data.errorType !== "InvalidRequest") { + return; + } + throw new LangSmithSandboxNotReadyError( + data.message || `Sandbox '${name}' is not ready`, + ); +} + +/** + * Handle HTTP errors and raise appropriate exceptions (for client operations). + */ +export async function handleClientHttpError( + response: Response, +): Promise { + const status = response.status; + // Only clone when we need to read the body twice (status 422 reads it again + // for structured validation details after parseErrorResponse consumes it). + const clonedResponse = status === 422 ? response.clone() : null; + const data = await parseErrorResponse(response); + const message = data.message; + const errorType = data.errorType; + + if (status === 401 || status === 403) { + throw new LangSmithSandboxAuthenticationError(message); + } + if (status === 404) { + throw new LangSmithResourceNotFoundError(message); + } + + // Handle validation errors (invalid resource values, formats, etc.) + if (status === 422 && clonedResponse) { + const details = await parseValidationError(clonedResponse); + const field = details[0]?.loc?.slice(-1)[0] as string | undefined; + throw new LangSmithValidationError(message, field, details); + } + + // Handle quota exceeded errors (org limits) + if (status === 429) { + const quotaType = extractQuotaType(message); + throw new LangSmithQuotaExceededError(message, quotaType); + } + + if (status === 502 && errorType === "ConnectionError") { + throw new LangSmithSandboxConnectionError(message); + } + if (status === 500) { + throw new LangSmithSandboxAPIError(message); + } + throw new LangSmithSandboxError(message); +} + +// ============================================================================= +// Sandbox Operation Error Handlers +// ============================================================================= + +/** + * Handle HTTP errors for sandbox operations (run, read, write). + * + * Maps API error types to specific exceptions: + * - WriteError -> LangSmithSandboxOperationError (operation="write") + * - ReadError -> LangSmithSandboxOperationError (operation="read") + * - CommandError -> LangSmithSandboxOperationError (operation="command") + * - ConnectionError (502) -> LangSmithSandboxConnectionError + * - FileNotFound / 404 -> LangSmithResourceNotFoundError (resourceType="file") + * - NotReady (400) -> LangSmithSandboxNotReadyError + * - 403 -> LangSmithSandboxOperationError (permission denied) + */ +export async function handleSandboxHttpError( + response: Response, +): Promise { + const data = await parseErrorResponse(response); + const message = data.message; + const errorType = data.errorType; + const status = response.status; + + // Operation-specific errors (from sandbox runtime) + if (errorType === "WriteError") { + throw new LangSmithSandboxOperationError(message, "write", errorType); + } + if (errorType === "ReadError") { + throw new LangSmithSandboxOperationError(message, "read", errorType); + } + if (errorType === "CommandError") { + throw new LangSmithSandboxOperationError(message, "command", errorType); + } + + // Permission denied + if (status === 403) { + throw new LangSmithSandboxOperationError( + message, + undefined, + "PermissionDenied", + ); + } + + // Connection to sandbox failed + if (status === 502 && errorType === "ConnectionError") { + throw new LangSmithSandboxConnectionError(message); + } + + // Not ready / not found + if (status === 400 && errorType === "NotReady") { + throw new LangSmithSandboxNotReadyError(message); + } + if (status === 404 || errorType === "FileNotFound") { + throw new LangSmithResourceNotFoundError(message, "file"); + } + + throw new LangSmithSandboxError(message); +} + +// ============================================================================= +// Run configuration +// ============================================================================= + +/** + * Reject a per-command `runConfig` alongside the deprecated `env`/`cwd`. + * + * The server answers 400 for a request carrying both spellings rather than + * letting one silently win, so refuse it here where the message can name the + * replacement. + */ +export function assertRunConfigNotCombined(options: { + runConfig?: RunConfig; + env?: Record; + cwd?: string; +}): void { + if ( + options.runConfig !== undefined && + (options.env !== undefined || options.cwd !== undefined) + ) { + throw new LangSmithValidationError( + "Cannot combine runConfig with the deprecated env/cwd options. " + + "Use runConfig.env_vars and runConfig.work_dir instead.", + ); + } +} + +/** + * Whether to half-close stdin at spawn. + * + * Defaults on for a non-PTY command: a command that reads stdin otherwise + * blocks on a pipe nobody writes to until the timeout kills it. A PTY has no + * separate write end to close, so the server ignores the flag there. + */ +export function resolveCloseInput( + closeInput: boolean | undefined, + pty: boolean, +): boolean { + if (pty) return false; + return closeInput ?? true; +} + +// ============================================================================= +// Ranged downloads +// ============================================================================= + +/** Render a byte range as an RFC 9110 `Range` header value. */ +export function buildRangeHeader(options: ReadRangeOptions): string { + const { start, end, suffixBytes } = options; + if (suffixBytes !== undefined) { + if (start !== undefined || end !== undefined) { + throw new LangSmithValidationError( + "Cannot combine suffixBytes with start/end.", + ); + } + if (suffixBytes <= 0) { + throw new LangSmithValidationError("suffixBytes must be positive."); + } + return `bytes=-${suffixBytes}`; + } + if (start === undefined) { + throw new LangSmithValidationError( + "Provide start (with optional end), or suffixBytes.", + ); + } + if (start < 0) { + throw new LangSmithValidationError("start must not be negative."); + } + if (end === undefined) { + return `bytes=${start}-`; + } + if (end < start) { + throw new LangSmithValidationError("end must not precede start."); + } + return `bytes=${start}-${end}`; +} + +/** Read the first byte offset and total size out of `Content-Range`. */ +function parseContentRange(value: string | null): { + start: number; + total?: number; +} { + if (!value || !value.startsWith("bytes ")) return { start: 0 }; + const spec = value.slice("bytes ".length).trim(); + const [rangePart, totalPart] = spec.split("/"); + const first = rangePart.split("-")[0].trim(); + const start = /^\d+$/.test(first) ? Number(first) : 0; + const total = /^\d+$/.test((totalPart ?? "").trim()) + ? Number(totalPart) + : undefined; + return { start, total }; +} + +/** Build a FileStat from a HEAD response's headers. */ +export function fileStatFromResponse(response: Response): FileStat { + const length = response.headers.get("content-length"); + return { + size_bytes: length && /^\d+$/.test(length) ? Number(length) : 0, + etag: response.headers.get("etag") ?? undefined, + last_modified: response.headers.get("last-modified") ?? undefined, + content_type: response.headers.get("content-type") ?? undefined, + }; +} + +/** Build a FileChunk from a ranged download response. */ +export async function fileChunkFromResponse( + response: Response, +): Promise { + const etag = response.headers.get("etag") ?? undefined; + const lastModified = response.headers.get("last-modified") ?? undefined; + if (response.status === 304) { + return { + content: new Uint8Array(0), + etag, + start: 0, + partial: false, + unchanged: true, + last_modified: lastModified, + }; + } + const content = new Uint8Array(await response.arrayBuffer()); + const partial = response.status === 206; + // A stale If-Range answers 200 with the whole file; the caller has to + // restart rather than append, so report it from byte zero. + const { start, total } = partial + ? parseContentRange(response.headers.get("content-range")) + : { start: 0, total: content.length }; + return { + content, + etag, + total_bytes: total, + start, + partial, + unchanged: false, + last_modified: lastModified, + }; +} + +/** Map a file-operation HTTP error, including the non-JSON 416. */ +export async function handleFileHttpError( + response: Response, + path: string, + sandboxName: string, +): Promise { + if (response.status === 404) { + throw new LangSmithResourceNotFoundError( + `File '${path}' not found in sandbox '${sandboxName}'`, + "file", + ); + } + if (response.status === 416) { + throw new LangSmithSandboxOperationError( + `Requested range for '${path}' starts past the end of the file ` + + `(${response.headers.get("content-range") ?? "unknown size"}).`, + "read", + ); + } + return handleSandboxHttpError(response); +} diff --git a/src/lib/sandbox/index.ts b/src/lib/sandbox/index.ts new file mode 100644 index 0000000..5d3a4ed --- /dev/null +++ b/src/lib/sandbox/index.ts @@ -0,0 +1,161 @@ +/** + * LangSmith Sandbox Module. + * + * This module provides sandboxed code execution capabilities through the + * LangSmith Sandbox API. + * + * @example + * ```typescript + * import { SandboxClient } from "langsmith/sandbox"; + * + * // Uses LANGSMITH_ENDPOINT and LANGSMITH_API_KEY from environment + * const client = new SandboxClient(); + * + * const snapshot = await client.createSnapshot( + * "python", + * "python:3.12-slim", + * 1_073_741_824 + * ); + * const sandbox = await client.createSandbox(snapshot.id); + * try { + * const result = await sandbox.run("python --version"); + * console.log(result.stdout); + * } finally { + * await sandbox.delete(); + * } + * ``` + * + * @packageDocumentation + */ + +// Main classes +export { SandboxClient } from "./client.js"; +export { Sandbox } from "./sandbox.js"; +export { CommandHandle } from "./command_handle.js"; +export { + awsAuth, + gcpAuth, + opaqueSecret, + proxyConfig, + workspaceSecret, +} from "./proxy_config.js"; +export { validateAccessDelegation } from "./access_delegation.js"; +export { + ServiceUrl, + ServiceLoginUrl, + SERVICE_TOKEN_HEADER, +} from "./service_url.js"; +export { + SandboxTokenVerifier, + USER_TOKEN_HEADER, + CALLBACK_SIGNATURE_HEADER, +} from "./verify.js"; +export type { + AudienceMatcher, + SandboxCallback, + SandboxCallbackIdentity, + SandboxCallbackRequest, + SandboxTokenVerifierConfig, + SandboxUser, + VerifyCallbackOptions, + VerifyUserTokenOptions, +} from "./verify.js"; +export { + contextHubMount, + gcsMount, + gitMount, + mountConfig, + s3Mount, +} from "./mounts.js"; + +// Types +export type { + AccessDelegation, + AccessDelegationMode, +} from "./access_delegation.js"; +export type { ServiceAccess, ServiceUrlData } from "./service_url.js"; +export type { + ExecutionResult, + FileChunk, + FileInfo, + FileStat, + GlobOptions, + GlobResult, + GrepMatch, + GrepOptions, + GrepResult, + ReadRangeOptions, + RunConfig, + OutputChunk, + WsMessage, + WsRunOptions, + ResourceStatus, + Snapshot, + SandboxData, + SandboxClientConfig, + RunOptions, + CreateSandboxOptions, + SandboxAccessControl, + SandboxAwsAuthRule, + SandboxAwsMountAuthConfig, + SandboxGcpAuthRule, + SandboxGcpMountAuthConfig, + SandboxMountAuth, + SandboxMountAuthConfig, + SandboxMountConfig, + SandboxProxyConfig, + SandboxProxyRule, + SandboxProxySecret, + SandboxMount, + MountCacheConfig, + ContextHubMountConfig, + ContextHubMountSpec, + GCSMountConfig, + GCSMountSpec, + GitMountConfig, + GitMountRefSpec, + GitMountSpec, + S3MountConfig, + S3MountSpec, + CreateSnapshotOptions, + CreateDockerfileSnapshotOptions, + CaptureSnapshotOptions, + DownloadContentDisposition, + DownloadURL, + GenerateDownloadURLOptions, + ListSnapshotsOptions, + WaitForSnapshotOptions, + StartSandboxOptions, + UpdateSandboxOptions, + WaitForSandboxOptions, +} from "./types.js"; + +// Errors +export { + // Base and connection errors + LangSmithSandboxError, + LangSmithSandboxAPIError, + LangSmithSandboxAuthenticationError, + LangSmithSandboxConnectionError, + LangSmithSandboxRetryableConnectionError, + LangSmithSandboxConnectTimeoutError, + LangSmithSandboxServerReloadError, + // Resource errors (type-based with resourceType attribute) + LangSmithResourceNotFoundError, + LangSmithResourceTimeoutError, + LangSmithResourceInUseError, + LangSmithResourceAlreadyExistsError, + LangSmithResourceNameConflictError, + // Validation and quota errors + LangSmithValidationError, + LangSmithQuotaExceededError, + // Resource creation errors + LangSmithResourceCreationError, + // Sandbox-specific errors + LangSmithSandboxCreationError, + LangSmithSandboxNotReadyError, + LangSmithSandboxOperationError, + LangSmithCommandTimeoutError, + LangSmithDataplaneNotConfiguredError, + LangSmithSandboxTokenVerificationError, +} from "./errors.js"; diff --git a/src/lib/sandbox/mounts.ts b/src/lib/sandbox/mounts.ts new file mode 100644 index 0000000..cddc4e3 --- /dev/null +++ b/src/lib/sandbox/mounts.ts @@ -0,0 +1,408 @@ +import type { + ContextHubMountSpec, + GCSMountSpec, + GitMountRefSpec, + GitMountSpec, + MountCacheConfig, + SandboxAwsAuthRule, + SandboxGcpAuthRule, + S3MountSpec, + SandboxMount, + SandboxMountAuth, + SandboxMountAuthConfig, + SandboxMountConfig, + SandboxProxyConfig, + SandboxProxyRule, + SandboxProxySecret, +} from "./types.js"; +import { getAwsRoleArn } from "./proxy_config.js"; + +function requireNonEmptyString(value: string, field: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${field} must be a non-empty string`); + } + return value.trim(); +} + +function copyMountSecret(secret: unknown, field: string): SandboxProxySecret { + if (secret === null || typeof secret !== "object" || Array.isArray(secret)) { + throw new Error(`${field} must be a sandbox secret`); + } + const candidate = secret as Partial; + if (candidate.type !== "workspace_secret" && candidate.type !== "opaque") { + throw new Error(`${field} must use workspace_secret or opaque`); + } + if (typeof candidate.value !== "string" || candidate.value.trim() === "") { + throw new Error(`${field}.value must be a non-empty string`); + } + return { + type: candidate.type, + value: candidate.value, + }; +} + +function requireGitRemoteUrl(remoteUrl: string): string { + if (typeof remoteUrl !== "string" || remoteUrl === "") { + throw new Error("remoteUrl must be a non-empty string"); + } + if ( + remoteUrl.trim() !== remoteUrl || + /\s/u.test(remoteUrl) || + remoteUrl.includes(String.fromCharCode(0)) + ) { + throw new Error("remoteUrl must not contain whitespace or NUL bytes"); + } + + let parsed: URL; + try { + parsed = new URL(remoteUrl); + } catch { + throw new Error("remoteUrl must be an absolute HTTPS URL"); + } + + if (parsed.protocol !== "https:" || parsed.host === "") { + throw new Error("remoteUrl must be an absolute HTTPS URL"); + } + if (parsed.username !== "" || parsed.password !== "") { + throw new Error("remoteUrl must not include embedded credentials"); + } + if (parsed.pathname === "" || parsed.pathname === "/") { + throw new Error("remoteUrl must include a repository path"); + } + if (parsed.search !== "" || parsed.hash !== "") { + throw new Error("remoteUrl must not include query or fragment"); + } + return remoteUrl; +} + +function copyGitRef(ref: GitMountRefSpec): GitMountRefSpec { + if (ref === null || typeof ref !== "object" || Array.isArray(ref)) { + throw new Error("ref must be an object"); + } + if (ref.type !== "branch" && ref.type !== "tag") { + throw new Error("ref.type must be branch or tag"); + } + return { + type: ref.type, + name: requireNonEmptyString(ref.name, "ref.name"), + }; +} + +export function s3Mount({ + id, + mountPath, + bucket, + region = "us-east-1", + prefix, + endpointUrl = "https://s3.amazonaws.com", + pathStyle = false, + readOnly, + cache, +}: { + id: string; + mountPath: string; + bucket: string; + region?: string; + prefix?: string; + endpointUrl?: string; + pathStyle?: boolean; + readOnly?: boolean; + cache?: MountCacheConfig; +}): S3MountSpec { + const mount: S3MountSpec = { + id: requireNonEmptyString(id, "id"), + type: "s3", + mount_path: requireNonEmptyString(mountPath, "mountPath"), + s3: { + endpoint_url: requireNonEmptyString(endpointUrl, "endpointUrl"), + region: requireNonEmptyString(region, "region"), + bucket: requireNonEmptyString(bucket, "bucket"), + path_style: pathStyle, + }, + }; + if (prefix !== undefined) { + mount.s3.prefix = requireNonEmptyString(prefix, "prefix"); + } + if (readOnly !== undefined) { + mount.read_only = readOnly; + } + if (cache !== undefined) { + mount.cache = { ...cache }; + } + return mount; +} + +export function gitMount({ + id, + mountPath, + remoteUrl, + ref, + refreshIntervalSeconds, +}: { + id: string; + mountPath: string; + remoteUrl: string; + ref?: GitMountRefSpec; + refreshIntervalSeconds?: number; +}): GitMountSpec { + const mount: GitMountSpec = { + id: requireNonEmptyString(id, "id"), + type: "git", + mount_path: requireNonEmptyString(mountPath, "mountPath"), + git: { + remote_url: requireGitRemoteUrl(remoteUrl), + }, + }; + if (ref !== undefined) { + mount.git.ref = copyGitRef(ref); + } + if (refreshIntervalSeconds !== undefined) { + if (refreshIntervalSeconds < 1) { + throw new Error("refreshIntervalSeconds must be at least 1"); + } + mount.git.refresh_interval_seconds = refreshIntervalSeconds; + } + return mount; +} + +export function gcsMount({ + id, + mountPath, + bucket, + prefix, + readOnly, + cache, +}: { + id: string; + mountPath: string; + bucket: string; + prefix?: string; + readOnly?: boolean; + cache?: MountCacheConfig; +}): GCSMountSpec { + const mount: GCSMountSpec = { + id: requireNonEmptyString(id, "id"), + type: "gcs", + mount_path: requireNonEmptyString(mountPath, "mountPath"), + gcs: { + bucket: requireNonEmptyString(bucket, "bucket"), + }, + }; + if (prefix !== undefined) { + mount.gcs.prefix = requireNonEmptyString(prefix, "prefix"); + } + if (readOnly !== undefined) { + mount.read_only = readOnly; + } + if (cache !== undefined) { + mount.cache = { ...cache }; + } + return mount; +} + +/** + * Build a read-only Context Hub mount. The sync is one-way: files written + * under the mount path inside the sandbox are never pushed back to the repo, + * and the next sync overwrites them. + */ +export function contextHubMount({ + id, + mountPath, + repo, + initialPullOnly, +}: { + id: string; + mountPath: string; + repo: string; + initialPullOnly?: boolean; +}): ContextHubMountSpec { + const mount: ContextHubMountSpec = { + id: requireNonEmptyString(id, "id"), + type: "contexthub", + mount_path: requireNonEmptyString(mountPath, "mountPath"), + contexthub: { + repo: requireNonEmptyString(repo, "repo"), + }, + }; + if (initialPullOnly !== undefined) { + mount.contexthub.initial_pull_only = initialPullOnly; + } + return mount; +} + +function normalizeMounts(mounts: SandboxMount[]): SandboxMount[] { + if (!Array.isArray(mounts) || mounts.length === 0) { + throw new Error("mounts must be a non-empty array of mount objects"); + } + return mounts.map((mount) => { + if (mount === null || typeof mount !== "object" || Array.isArray(mount)) { + throw new Error("mounts must be a non-empty array of mount objects"); + } + if ( + mount.type !== "s3" && + mount.type !== "gcs" && + mount.type !== "git" && + mount.type !== "contexthub" + ) { + throw new Error( + "mountConfig only supports s3, gcs, git, and contexthub mounts", + ); + } + rejectProviderCredentialsInMount(mount); + return mount; + }); +} + +const FORBIDDEN_MOUNT_CREDENTIAL_FIELDS = new Set([ + "access_key_id", + "secret_access_key", + "session_token", + "service_account_json", + "access_token", + "refresh_token", + "credentials", + "credential", +]); + +function rejectProviderCredentialsInMount(value: unknown): void { + if (Array.isArray(value)) { + for (const child of value) { + rejectProviderCredentialsInMount(child); + } + return; + } + if (value !== null && typeof value === "object") { + for (const [key, child] of Object.entries(value)) { + if (FORBIDDEN_MOUNT_CREDENTIAL_FIELDS.has(key)) { + throw new Error( + "provider credentials must be supplied in mountConfig.auth, not individual mount specs", + ); + } + rejectProviderCredentialsInMount(child); + } + } +} + +function normalizeMountAuth(auth: SandboxMountAuth[]): SandboxMountAuthConfig { + if (!Array.isArray(auth)) { + throw new Error("auth must be an array of provider auth blocks"); + } + const byProvider: SandboxMountAuthConfig = {}; + for (const block of auth) { + if (block === null || typeof block !== "object" || Array.isArray(block)) { + throw new Error("auth must be an array of provider auth blocks"); + } + const provider = (block as unknown as Record).type; + if (provider !== "aws" && provider !== "gcp") { + throw new Error("mountConfig auth only supports aws and gcp blocks"); + } + if (byProvider[provider] !== undefined) { + throw new Error(`duplicate ${provider} auth rule in mountConfig`); + } + if (provider === "aws") { + const aws = (block as Partial).aws; + if (aws === undefined) { + throw new Error("aws mount auth must include an aws block"); + } + const roleArn = getAwsRoleArn(aws); + if (roleArn !== undefined) { + byProvider.aws = { role_arn: roleArn }; + continue; + } + byProvider.aws = { + access_key_id: copyMountSecret(aws.access_key_id, "accessKeyId"), + secret_access_key: copyMountSecret( + aws.secret_access_key, + "secretAccessKey", + ), + }; + } else { + const gcp = (block as Partial).gcp; + if (gcp === undefined) { + throw new Error("gcp mount auth must include a gcp block"); + } + byProvider.gcp = { + service_account_json: copyMountSecret( + gcp.service_account_json, + "serviceAccountJson", + ), + }; + } + } + return byProvider; +} + +/** + * Build mount-scoped auth, or use an enabled AWS rule from proxyConfig for S3. + * Pass the same proxyConfig to sandbox creation; its general IAM permissions + * remain unchanged. GCS mounts still require explicit GCP auth in this config. + */ +export function mountConfig({ + auth = [], + mounts, + proxyConfig, +}: { + auth?: SandboxMountAuth[]; + mounts: SandboxMount[]; + proxyConfig?: SandboxProxyConfig; +}): SandboxMountConfig { + const normalizedMounts = normalizeMounts(mounts); + const authByProvider = normalizeMountAuth(auth); + const mountProviders = new Set(normalizedMounts.map((mount) => mount.type)); + const proxyProviders = new Set( + (proxyConfig?.rules ?? []) + .filter((rule) => rule?.enabled === true) + .map((rule) => rule.type), + ); + + if ( + mountProviders.has("s3") && + authByProvider.aws === undefined && + !proxyProviders.has("aws") + ) { + throw new Error("s3 mounts require aws auth in mountConfig"); + } + if (mountProviders.has("gcs") && authByProvider.gcp === undefined) { + throw new Error("gcs mounts require gcp auth in mountConfig"); + } + if (authByProvider.aws !== undefined && !mountProviders.has("s3")) { + throw new Error("aws auth requires at least one s3 mount in mountConfig"); + } + if (authByProvider.gcp !== undefined && !mountProviders.has("gcs")) { + throw new Error("gcp auth requires at least one gcs mount in mountConfig"); + } + + const config: SandboxMountConfig = { + auth: authByProvider, + mounts: normalizedMounts, + }; + validateMountConfigProxyConfig(config, proxyConfig); + return config; +} + +export function validateMountConfigProxyConfig( + mountConfig: SandboxMountConfig, + proxyConfig: SandboxProxyConfig | undefined, +): void { + if (proxyConfig === undefined) { + return; + } + const rules = proxyConfig.rules ?? []; + if (!Array.isArray(rules)) { + throw new Error("proxyConfig rules must be an array"); + } + for (const rule of rules) { + if (rule === null || typeof rule !== "object" || Array.isArray(rule)) { + continue; + } + const ruleType = (rule as SandboxProxyRule).type; + if ( + (ruleType === "aws" || ruleType === "gcp") && + mountConfig.auth[ruleType] !== undefined + ) { + throw new Error( + `${ruleType} auth cannot be provided in both mountConfig and proxyConfig`, + ); + } + } +} diff --git a/src/lib/sandbox/proxy_config.ts b/src/lib/sandbox/proxy_config.ts new file mode 100644 index 0000000..0d67e31 --- /dev/null +++ b/src/lib/sandbox/proxy_config.ts @@ -0,0 +1,249 @@ +import type { + SandboxAccessControl, + SandboxAwsAuthRule, + SandboxGcpAuthRule, + SandboxProxyConfig, + SandboxProxyRule, + SandboxProxySecret, +} from "./types.js"; + +function requireNonEmptyString(value: string, field: string): string { + if (typeof value !== "string" || value.trim() === "") { + throw new Error(`${field} must be a non-empty string`); + } + return value.trim(); +} + +function requireNonEmptyStringArray(values: string[], field: string): string[] { + if (!Array.isArray(values) || values.length === 0) { + throw new Error(`${field} must be a non-empty array of strings`); + } + return values.map((value) => requireNonEmptyString(value, field)); +} + +function requireEnvVars( + envVars: Record, +): Record { + if ( + envVars === null || + typeof envVars !== "object" || + Array.isArray(envVars) + ) { + throw new Error("envVars must be a non-empty object of names to values"); + } + const entries = Object.entries(envVars); + if (entries.length === 0) { + throw new Error("envVars must be a non-empty object of names to values"); + } + return Object.fromEntries( + entries.map(([name, value]) => { + // Validated on the trimmed form, stored verbatim: whitespace can be significant. + requireNonEmptyString(value, `envVars[${name}]`); + return [requireNonEmptyString(name, "envVars name"), value]; + }), + ); +} + +function requireProxyRules( + rules: SandboxProxyRule[] | undefined, +): SandboxProxyRule[] { + if (rules === undefined) { + return []; + } + if (!Array.isArray(rules)) { + throw new Error("rules must be an array of proxy rule objects"); + } + return rules.map((rule) => { + if (rule === null || typeof rule !== "object" || Array.isArray(rule)) { + throw new Error("rules must be an array of proxy rule objects"); + } + validateProxyProviderRule(rule); + return rule; + }); +} + +function validateProxyProviderRule(rule: SandboxProxyRule): void { + if (rule.type === "aws") { + getAwsRoleArn(rule.aws); + return; + } + if ((rule as Record).type !== "gcp") { + return; + } + const gcp = (rule as Partial).gcp; + if (gcp === undefined || gcp.scopes === undefined) { + throw new Error("gcp proxy auth rules require scopes"); + } + requireNonEmptyStringArray(gcp.scopes, "scopes"); +} + +/** Validate role-only descriptors without tightening legacy static inputs. */ +export function getAwsRoleArn(aws: unknown): string | undefined { + if (aws === null || typeof aws !== "object" || Array.isArray(aws)) { + return undefined; + } + const config = aws as Record; + if (!("role_arn" in config) || config.role_arn === "") { + return undefined; + } + const roleArn = requireNonEmptyString(config.role_arn as string, "roleArn"); + if (Object.keys(config).some((key) => key !== "role_arn")) { + throw new Error("AWS role auth must contain only role_arn"); + } + return roleArn; +} + +/** Reference a LangSmith workspace secret in a sandbox proxy configuration. */ +export function workspaceSecret(name: string): SandboxProxySecret { + const normalized = requireNonEmptyString(name, "name"); + const startsWithBrace = normalized.startsWith("{"); + const endsWithBrace = normalized.endsWith("}"); + if (startsWithBrace !== endsWithBrace) { + throw new Error("workspace secret must be a name or a {NAME} reference"); + } + if (startsWithBrace && normalized.slice(1, -1).trim() === "") { + throw new Error("workspace secret reference must contain a name"); + } + return { + type: "workspace_secret", + value: startsWithBrace ? normalized : `{${normalized}}`, + }; +} + +/** Provide a write-only secret value for a sandbox proxy configuration. */ +export function opaqueSecret(value: string): SandboxProxySecret { + return { + type: "opaque", + value: requireNonEmptyString(value, "value"), + }; +} + +/** Build a sandbox proxy config from one or more proxy rules. */ +export function proxyConfig({ + rules, + accessControl, +}: { + rules?: SandboxProxyRule[]; + /** @deprecated Ignored. The sandbox runtime has no proxy bypass list. */ + noProxy?: string[]; + accessControl?: SandboxAccessControl; +} = {}): SandboxProxyConfig { + const config: SandboxProxyConfig = { + rules: requireProxyRules(rules), + }; + if (accessControl !== undefined) { + config.access_control = { ...accessControl }; + } + return config; +} + +interface AwsAuthCommonOptions { + name?: string; + enabled?: boolean; + envVars?: Record; +} + +type AwsStaticAuthConfig = Extract< + SandboxAwsAuthRule["aws"], + { access_key_id: SandboxProxySecret } +>; + +type AwsRoleAuthConfig = Extract< + SandboxAwsAuthRule["aws"], + { role_arn: string } +>; + +type AwsStaticAuthOptions = AwsAuthCommonOptions & { + accessKeyId: SandboxProxySecret; + secretAccessKey: SandboxProxySecret; + roleArn?: ""; +}; + +type AwsRoleAuthOptions = AwsAuthCommonOptions & { + roleArn: string; + accessKeyId?: never; + secretAccessKey?: never; +}; + +/** + * Sign supported AWS HTTPS requests using static keys or an IAM role. + * Role auth requires backend support and is configured at sandbox creation. + * LangSmith supplies the workspace External ID and renews credentials. + * A role in proxyConfig uses its effective IAM permissions; the same helper + * in mountConfig.auth uses the backend's mount-scoped S3 permissions. + */ +export function awsAuth( + options: AwsStaticAuthOptions, +): SandboxAwsAuthRule; +export function awsAuth( + options: AwsRoleAuthOptions, +): SandboxAwsAuthRule; +export function awsAuth( + options: AwsStaticAuthOptions | AwsRoleAuthOptions, +): SandboxAwsAuthRule; +export function awsAuth({ + accessKeyId, + secretAccessKey, + roleArn, + name = "aws", + enabled = true, + envVars, +}: AwsStaticAuthOptions | AwsRoleAuthOptions): SandboxAwsAuthRule { + const candidate: Record = {}; + if (roleArn !== undefined) candidate.role_arn = roleArn; + if (accessKeyId !== undefined) candidate.access_key_id = accessKeyId; + if (secretAccessKey !== undefined) + candidate.secret_access_key = secretAccessKey; + const normalizedRole = getAwsRoleArn(candidate); + let aws: SandboxAwsAuthRule["aws"]; + if (normalizedRole !== undefined) { + aws = { role_arn: normalizedRole }; + } else { + if (accessKeyId === undefined || secretAccessKey === undefined) { + throw new Error("AWS auth requires roleArn or both static credentials"); + } + aws = { access_key_id: accessKeyId, secret_access_key: secretAccessKey }; + } + const rule: SandboxAwsAuthRule = { + name: requireNonEmptyString(name, "name"), + type: "aws", + enabled, + aws, + }; + if (envVars !== undefined) { + rule.env_vars = requireEnvVars(envVars); + } + return rule; +} + +/** Build a sandbox proxy rule that injects GCP OAuth bearer auth. */ +export function gcpAuth({ + serviceAccountJson, + scopes, + name = "gcp", + enabled = true, + envVars, +}: { + serviceAccountJson: SandboxProxySecret; + scopes?: string[]; + name?: string; + enabled?: boolean; + envVars?: Record; +}): SandboxGcpAuthRule { + const gcp: SandboxGcpAuthRule["gcp"] = { + service_account_json: serviceAccountJson, + }; + if (scopes !== undefined) { + gcp.scopes = requireNonEmptyStringArray(scopes, "scopes"); + } + const rule: SandboxGcpAuthRule = { + name: requireNonEmptyString(name, "name"), + type: "gcp", + enabled, + gcp, + }; + if (envVars !== undefined) { + rule.env_vars = requireEnvVars(envVars); + } + return rule; +} diff --git a/src/lib/sandbox/sandbox.ts b/src/lib/sandbox/sandbox.ts new file mode 100644 index 0000000..4aa214b --- /dev/null +++ b/src/lib/sandbox/sandbox.ts @@ -0,0 +1,769 @@ +/** + * Sandbox class for interacting with a specific sandbox instance. + */ + +import type { AccessDelegation } from "./access_delegation.js"; +import type { SandboxClient } from "./client.js"; +import type { + CaptureSnapshotOptions, + DownloadURL, + ExecutionResult, + FileChunk, + FileStat, + GenerateDownloadURLOptions, + GlobOptions, + GlobResult, + GrepOptions, + GrepResult, + ReadRangeOptions, + RunConfig, + RunOptions, + SandboxData, + Snapshot, + StartSandboxOptions, +} from "./types.js"; +import { uuid7 } from "../uuid.js"; +import { + LangSmithDataplaneNotConfiguredError, + LangSmithSandboxRetryableConnectionError, + LangSmithStreamEndedBeforeStartedError, +} from "./errors.js"; +import { + assertRunConfigNotCombined, + buildRangeHeader, + fileChunkFromResponse, + fileStatFromResponse, + handleFileHttpError, + handleSandboxHttpError, + resolveCloseInput, +} from "./helpers.js"; +import { CommandHandle } from "./command_handle.js"; +import { + connectDeadline, + isWsAvailable, + openTimeoutFor, + reconnectWsStream, + remainingBudget, + runWsStream, +} from "./ws_execute.js"; + +/** + * Represents an active sandbox for running commands and file operations. + * + * This class is typically obtained from SandboxClient.createSandbox() and + * provides methods for command execution and file I/O within the sandbox + * environment. + * + * @example + * ```typescript + * const sandbox = await client.createSandbox(snapshot.id); + * try { + * const result = await sandbox.run("python --version"); + * console.log(result.stdout); + * } finally { + * await sandbox.delete(); + * } + * ``` + */ +export class Sandbox { + /** Display name (can be updated). */ + readonly name: string; + /** URL for data plane operations (file I/O, command execution). */ + dataplane_url?: string; + /** Provisioning status ("provisioning", "ready", "failed", "stopped"). */ + status?: string; + /** Human-readable status message (e.g., error details when failed). */ + readonly status_message?: string; + /** Unique identifier (UUID). Remains constant even if name changes. */ + readonly id?: string; + /** Timestamp when the sandbox was created. */ + readonly created_at?: string; + /** Timestamp when the sandbox was last updated. */ + readonly updated_at?: string; + /** + * Idle timeout TTL in seconds (`0` means disabled). + * New sandboxes receive a server-side default of `600` seconds (10 minutes) + * when the caller did not set `idleTtlSeconds` explicitly. The launcher + * stops the sandbox after this many idle seconds. + */ + readonly idle_ttl_seconds?: number; + /** + * Seconds after the sandbox enters the `stopped` state before it (and + * its filesystem clone) are permanently deleted (`0` means disabled). + */ + readonly delete_after_stop_seconds?: number; + /** + * Timestamp when the sandbox transitioned to `stopped`, or `undefined` + * while running. The deletion deadline is + * `stopped_at + delete_after_stop_seconds`. + */ + readonly stopped_at?: string; + /** Snapshot ID used to create this sandbox. */ + readonly snapshot_id?: string; + /** Number of vCPUs allocated. */ + readonly vCpus?: number; + /** Memory allocation in bytes. */ + readonly mem_bytes?: number; + /** Root filesystem capacity in bytes. */ + readonly fs_capacity_bytes?: number; + /** + * User, working directory and environment this sandbox's commands run + * with. Absent on sandboxes created before the server recorded it. + */ + readonly run_config?: RunConfig; + /** + * LangSmith access granted to code inside the sandbox, or undefined when it + * has none. + */ + readonly access_delegation?: AccessDelegation; + + private _client: SandboxClient; + + /** @internal */ + constructor(data: SandboxData, client: SandboxClient) { + this.name = data.name; + this.dataplane_url = data.dataplane_url; + this.status = data.status; + this.status_message = data.status_message; + this.id = data.id; + this.created_at = data.created_at; + this.updated_at = data.updated_at; + this.idle_ttl_seconds = data.idle_ttl_seconds; + this.delete_after_stop_seconds = data.delete_after_stop_seconds; + this.stopped_at = data.stopped_at ?? undefined; + this.snapshot_id = data.snapshot_id; + this.vCpus = data.vcpus; + this.mem_bytes = data.mem_bytes; + this.fs_capacity_bytes = data.fs_capacity_bytes; + this.run_config = data.run_config; + this.access_delegation = data.access_delegation; + this._client = client; + } + + /** + * Return the dataplane URL. + * + * The client does not gate on lifecycle status: a stopped sandbox is resumed + * by the platform when the dataplane request arrives, so only the presence of + * a URL is required here. A genuinely not-ready box surfaces the server's + * LangSmithSandboxNotReadyError from the request itself. + * + * @throws LangSmithDataplaneNotConfiguredError if dataplane_url is not configured. + */ + private requireDataplaneUrl(): string { + if (!this.dataplane_url) { + throw new LangSmithDataplaneNotConfiguredError( + `Sandbox '${this.name}' does not have a dataplane_url configured. ` + + "Runtime operations require a dataplane URL.", + ); + } + return this.dataplane_url; + } + + /** + * Execute a command in the sandbox. + * + * When `wait` is true (default) and no streaming callbacks are provided, + * uses WebSocket, falling back to HTTP POST only when the optional `ws` + * package isn't installed. + * + * When `wait` is false or streaming callbacks are provided, uses WebSocket + * (required). Returns a CommandHandle for streaming output. + * + * @param command - Shell command to execute. + * @param options - Execution options. + * @returns ExecutionResult when wait=true, CommandHandle when wait=false. + * + * @example + * ```typescript + * // Blocking (default) + * const result = await sandbox.run("echo hello"); + * console.log(result.stdout); + * + * // Streaming with callbacks + * const result = await sandbox.run("make build", { + * onStdout: (data) => process.stdout.write(data), + * }); + * + * // Non-blocking with CommandHandle + * const handle = await sandbox.run("make build", { wait: false }); + * for await (const chunk of handle) { + * process.stdout.write(chunk.data); + * } + * const result = await handle.result; + * ``` + */ + async run( + command: string, + options: RunOptions & { wait: false }, + ): Promise; + async run( + command: string, + options?: RunOptions & { wait?: true }, + ): Promise; + async run( + command: string, + options?: RunOptions, + ): Promise; + async run( + command: string, + options: RunOptions = {}, + ): Promise { + const { + wait = true, + onStdout, + onStderr, + idleTimeout, + killOnDisconnect, + ttlSeconds, + pty, + closeInput, + ...restOptions + } = options; + const hasCallbacks = onStdout !== undefined || onStderr !== undefined; + assertRunConfigNotCombined(restOptions); + const closeStdin = resolveCloseInput(closeInput, pty ?? false); + + if (!wait || hasCallbacks) { + // WebSocket required for streaming / non-blocking + const handle = await this._runWs(command, { + ...restOptions, + idleTimeout, + killOnDisconnect, + ttlSeconds, + pty, + closeInput: closeStdin, + onStdout, + onStderr, + }); + + if (!wait) { + return handle; + } + + // wait=true with callbacks: drain stream and return result + return handle.result; + } + + // wait=true, no callbacks: use WebSocket when the 'ws' package is + // available, otherwise the blocking HTTP endpoint. WS errors propagate. + if (await this._wsAvailable()) { + const handle = await this._runWs(command, { + ...restOptions, + idleTimeout, + killOnDisconnect, + ttlSeconds, + pty, + closeInput: closeStdin, + }); + return await handle.result; + } + return this._runHttp(command, restOptions); + } + + /** + * Whether the optional `ws` package is importable (resolved once). + * @internal + */ + protected _wsAvailable(): Promise { + return isWsAvailable(); + } + + /** + * Execute a command via WebSocket streaming. + * @internal + */ + private async _runWs( + command: string, + options: Omit = {}, + ): Promise { + const { + timeout = 60, + env, + cwd, + shell = "/bin/bash", + onStdout, + onStderr, + commandId, + idleTimeout, + killOnDisconnect, + ttlSeconds, + pty, + runConfig, + closeInput: closeStdin = false, + } = options; + const dataplaneUrl = this.requireDataplaneUrl(); + + const clientHeaders = this._client.getDefaultHeaders(); + + // A client-supplied command_id makes execute idempotent: the daemon does + // get-or-create keyed on it, so if the tunnel closes before "started" we + // can re-issue the same id and reattach to the existing command instead of + // spawning a second one. + const execCommandId = commandId ?? uuid7(); + + let attempt = 0; + const deadline = connectDeadline(); + // eslint-disable-next-line no-constant-condition + while (true) { + const [stream, control] = await runWsStream( + dataplaneUrl, + this._client.getApiKey(), + command, + { + timeout, + env, + cwd, + shell, + commandId: execCommandId, + idleTimeout, + killOnDisconnect, + ttlSeconds, + pty, + runConfig, + closeStdin, + openTimeout: openTimeoutFor(deadline), + ...(Object.keys(clientHeaders).length > 0 + ? { headers: clientHeaders } + : {}), + }, + ); + + const handle = new CommandHandle(stream, control, this, { + onStdout, + onStderr, + stdinClosed: closeStdin, + pty: pty ?? false, + }); + try { + await handle._ensureStarted(); + return handle; + } catch (e) { + // Idempotent re-issue (same command_id): neither an early close nor a + // failed connect can have started a second command. + if ( + !(e instanceof LangSmithStreamEndedBeforeStartedError) && + !(e instanceof LangSmithSandboxRetryableConnectionError) + ) { + throw e; + } + attempt++; + if (attempt > CommandHandle.MAX_AUTO_RECONNECTS) { + throw e; + } + let delay = Math.min( + CommandHandle.BACKOFF_BASE * 2 ** (attempt - 1), + CommandHandle.BACKOFF_MAX, + ); + if (e instanceof LangSmithSandboxRetryableConnectionError) { + delay = e.retryAfterSeconds ?? delay * (0.8 + Math.random() * 0.2); + } + const remaining = remainingBudget(deadline); + if (remaining !== undefined) { + if (remaining <= 0) { + throw e; + } + delay = Math.min(delay, remaining); + } + await new Promise((r) => setTimeout(r, delay * 1000)); + // Never start an attempt with no budget left: openTimeoutFor would + // return 0, which ws forwards to Node as "no timeout at all". + const left = remainingBudget(deadline); + if (left !== undefined && left <= 0) { + throw e; + } + } + } + } + + /** + * Execute a command via HTTP POST (blocking). + * @internal + */ + private async _runHttp( + command: string, + options: Omit = {}, + ): Promise { + const { timeout = 60, env, cwd, runConfig, shell = "/bin/bash" } = options; + const dataplaneUrl = this.requireDataplaneUrl(); + const url = `${dataplaneUrl}/execute`; + + const payload: Record = { + command, + timeout, + shell, + }; + if (env !== undefined) { + payload.env = env; + } + if (runConfig !== undefined) { + payload.run_config = runConfig; + } + if (cwd !== undefined) { + payload.cwd = cwd; + } + + const response = await this._client._fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout((timeout + 10) * 1000), + }); + + if (!response.ok) { + await handleSandboxHttpError(response); + } + + const data = await response.json(); + return { + stdout: data.stdout ?? "", + stderr: data.stderr ?? "", + exit_code: data.exit_code ?? -1, + }; + } + + /** + * Reconnect to a running command by its command ID. + * + * Returns a new CommandHandle that resumes output from the given offsets. + * + * @param commandId - The server-assigned command ID. + * @param options - Reconnection options with byte offsets. + * @returns A new CommandHandle. + */ + async reconnect( + commandId: string, + options: { + stdoutOffset?: number; + stderrOffset?: number; + stdinClosed?: boolean; + pty?: boolean; + } = {}, + ): Promise { + const { + stdoutOffset = 0, + stderrOffset = 0, + stdinClosed = false, + pty = false, + } = options; + const dataplaneUrl = this.requireDataplaneUrl(); + + const clientHeaders = this._client.getDefaultHeaders(); + const [stream, control] = await reconnectWsStream( + dataplaneUrl, + this._client.getApiKey(), + commandId, + { + stdoutOffset, + stderrOffset, + ...(Object.keys(clientHeaders).length > 0 + ? { headers: clientHeaders } + : {}), + }, + ); + + return new CommandHandle(stream, control, this, { + commandId, + stdoutOffset, + stderrOffset, + stdinClosed, + pty, + }); + } + + /** + * Write content to a file in the sandbox. + * + * @param path - Target file path in the sandbox. + * @param content - File content (string or bytes). + * @param timeout - Request timeout in seconds. + * + * @example + * ```typescript + * await sandbox.write("/tmp/script.py", 'print("Hello!")'); + * ``` + */ + async write( + path: string, + content: string | Uint8Array, + timeout = 60, + ): Promise { + const dataplaneUrl = this.requireDataplaneUrl(); + const url = `${dataplaneUrl}/upload?path=${encodeURIComponent(path)}`; + + // Ensure content is bytes for multipart upload + const bytes = + typeof content === "string" ? new TextEncoder().encode(content) : content; + + const formData = new FormData(); + // Create a copy to ensure we have a plain ArrayBuffer (not SharedArrayBuffer) + const buffer = new Uint8Array(bytes).buffer as ArrayBuffer; + const blob = new Blob([buffer], { type: "application/octet-stream" }); + formData.append("file", blob, "file"); + + const response = await this._client._fetch(url, { + method: "POST", + body: formData, + signal: AbortSignal.timeout(timeout * 1000), + }); + + if (!response.ok) { + await handleSandboxHttpError(response); + } + } + + /** + * Read a file from the sandbox. + * + * @param path - File path to read. + * @param timeout - Request timeout in seconds. + * @returns File contents as Uint8Array. + * + * @example + * ```typescript + * const content = await sandbox.read("/tmp/output.txt"); + * const text = new TextDecoder().decode(content); + * console.log(text); + * ``` + */ + async read(path: string, timeout = 60): Promise { + const dataplaneUrl = this.requireDataplaneUrl(); + const url = `${dataplaneUrl}/download?path=${encodeURIComponent(path)}`; + + const response = await this._client._fetch(url, { + method: "GET", + signal: AbortSignal.timeout(timeout * 1000), + }); + + if (!response.ok) { + await handleSandboxHttpError(response); + } + + const buffer = await response.arrayBuffer(); + return new Uint8Array(buffer); + } + + /** + * Report a file's size and validators without transferring it. + * + * @param path - File path to stat. + * @param timeout - Request timeout in seconds. + * @returns The size and the `ETag` to pass to {@link readRange}. + */ + async stat(path: string, timeout = 60): Promise { + const dataplaneUrl = this.requireDataplaneUrl(); + const url = `${dataplaneUrl}/download?path=${encodeURIComponent(path)}`; + + const response = await this._client._fetch(url, { + method: "HEAD", + signal: AbortSignal.timeout(timeout * 1000), + }); + if (!response.ok) { + await handleFileHttpError(response, path, this.name); + } + return fileStatFromResponse(response); + } + + /** + * Read part of a file, for chunked reads and resumed downloads. + * + * @param path - File path to read. + * @param options - The byte range, plus optional `ifRange`/`ifNoneMatch` + * validators. A 200 answer to a ranged request means the file changed and + * the server sent it whole -- restart rather than append. + * @returns The bytes and where they sit in the file. + * + * @example + * ```typescript + * const head = await sandbox.readRange("/big.bin", { start: 0, end: 1023 }); + * const next = await sandbox.readRange("/big.bin", { + * start: head.end ?? 1024, + * ifRange: head.etag, + * }); + * ``` + */ + async readRange(path: string, options: ReadRangeOptions): Promise { + const { ifRange, ifNoneMatch, timeout = 60, headers } = options; + const dataplaneUrl = this.requireDataplaneUrl(); + const url = `${dataplaneUrl}/download?path=${encodeURIComponent(path)}`; + + const requestHeaders: Record = { + ...(headers ?? {}), + Range: buildRangeHeader(options), + }; + if (ifRange) requestHeaders["If-Range"] = ifRange; + if (ifNoneMatch) requestHeaders["If-None-Match"] = ifNoneMatch; + + const response = await this._client._fetch(url, { + method: "GET", + headers: requestHeaders, + signal: AbortSignal.timeout(timeout * 1000), + }); + if (!response.ok && response.status !== 304) { + await handleFileHttpError(response, path, this.name); + } + return fileChunkFromResponse(response); + } + + /** + * Find files and directories matching a pattern. + * + * @param pattern - Match against each entry's path relative to `path`. + * Supports `**` for any number of segments plus `*`, `?` and `[...]` + * within one segment. + * @param path - Absolute path of the directory to search under. + * @param options - Result cap and request options. + * @returns The matches; check `truncated` before treating them as complete. + */ + async glob( + pattern: string, + path: string, + options: GlobOptions = {}, + ): Promise { + const body: Record = { pattern, path }; + if (options.limit !== undefined) body.limit = options.limit; + return (await this._fileSearch("glob", body, options)) as GlobResult; + } + + /** + * List a directory's immediate entries, without recursing. + * + * A convenience over {@link glob} with the pattern `*`. + * + * @param path - Absolute path of the directory to list. + * @param options - Result cap and request options. + * @returns The directory's files and subdirectories. + */ + async ls(path: string, options: GlobOptions = {}): Promise { + return this.glob("*", path, options); + } + + /** + * Search file contents for a literal string. + * + * @param pattern - Literal text to search for. Not a regular expression. + * @param path - Absolute path of the directory to search under. + * @param options - Optional `glob` file filter, result cap, request options. + * @returns The matching lines; check `truncated` for completeness. + */ + async grep( + pattern: string, + path: string, + options: GrepOptions = {}, + ): Promise { + const body: Record = { pattern, path }; + if (options.glob !== undefined) body.glob = options.glob; + if (options.limit !== undefined) body.limit = options.limit; + return (await this._fileSearch("grep", body, options)) as GrepResult; + } + + /** + * POST one of the read-only filesystem search endpoints. + * @internal + */ + private async _fileSearch( + operation: "glob" | "grep", + body: Record, + options: GlobOptions, + ): Promise { + const { timeout = 60, headers } = options; + const dataplaneUrl = this.requireDataplaneUrl(); + + const response = await this._client._fetch(`${dataplaneUrl}/${operation}`, { + method: "POST", + headers: { "Content-Type": "application/json", ...(headers ?? {}) }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout * 1000), + }); + if (!response.ok) { + await handleFileHttpError(response, String(body.path), this.name); + } + return response.json(); + } + + /** + * Create a link that downloads one file from this sandbox. + * + * The link carries its own token, so anyone holding the URL can fetch that + * one file without a LangSmith credential. Fetching wakes a stopped sandbox. + * + * Do not modify the file after minting a link for it. The link is pinned to + * a path, not to a snapshot of the contents, so a later write to that path + * may or may not be reflected in what the link serves. Write a new file and + * mint a new link when the contents change. + * + * @param path - File path inside the sandbox. + * @param options - Expiry and response header overrides. + * @returns The link and its expiry, if any. + * + * @example + * ```typescript + * const link = await sandbox.generateDownloadURL("/tmp/report.pdf", { + * expiresInSeconds: 3600, + * }); + * console.log(link.download_url); + * ``` + */ + async generateDownloadURL( + path: string, + options: GenerateDownloadURLOptions = {}, + ): Promise { + return this._client.generateDownloadURL(this.name, path, options); + } + + /** + * Delete this sandbox. + * + * @example + * ```typescript + * const sandbox = await client.createSandbox(snapshot.id); + * try { + * await sandbox.run("echo hello"); + * } finally { + * await sandbox.delete(); + * } + * ``` + */ + async delete(): Promise { + await this._client.deleteSandbox(this.name); + } + + /** + * Start a stopped sandbox and wait until ready. + * + * Updates this sandbox's status and dataplane_url in place. + * + * @param timeout - Timeout in seconds when waiting for ready. Default: 120. + */ + async start(options: StartSandboxOptions = {}): Promise { + const refreshed = await this._client.startSandbox(this.name, options); + this.status = refreshed.status; + this.dataplane_url = refreshed.dataplane_url; + } + + /** + * Stop a running sandbox (preserves sandbox files for later restart). + */ + async stop(): Promise { + await this._client.stopSandbox(this.name); + // dataplane_url stays set: it is stable across stop/start and a request on + // it resumes the sandbox. + this.status = "stopped"; + } + + /** + * Capture a snapshot from this sandbox. + * + * @param name - Snapshot name. + * @param options - Capture options (timeout). + * @returns Snapshot in "ready" status. + */ + async captureSnapshot( + name: string, + options: CaptureSnapshotOptions = {}, + ): Promise { + return this._client.captureSnapshot(this.name, name, options); + } +} diff --git a/src/lib/sandbox/service_url.ts b/src/lib/sandbox/service_url.ts new file mode 100644 index 0000000..a459585 --- /dev/null +++ b/src/lib/sandbox/service_url.ts @@ -0,0 +1,105 @@ +/** How a service URL is gated. Omit for a minted token. */ +export type ServiceAccess = "restricted" | "workspace"; + +/** Header the sandbox router reads the minted service token from. */ +export const SERVICE_TOKEN_HEADER = "X-Langsmith-Sandbox-Service-Token"; + +/** Refresh this many seconds before the token actually expires. */ +const REFRESH_MARGIN_SECONDS = 60; + +export interface ServiceUrlData { + browser_url?: string; + service_url?: string; + token?: string; + expires_at?: string; + access?: string; +} + +/** + * Service URL carrying a short-lived token, refreshed as it nears expiry. + * + * Accessors are async because a refresh is a network call. + */ +export class ServiceUrl { + private _browserUrl: string; + private _serviceUrl: string; + private _token: string; + private _expiresAt: string; + private readonly _refresher?: () => Promise; + + constructor(data: ServiceUrlData, refresher?: () => Promise) { + this._browserUrl = data.browser_url ?? ""; + this._serviceUrl = data.service_url ?? ""; + this._token = data.token ?? ""; + this._expiresAt = data.expires_at ?? ""; + this._refresher = refresher; + } + + private _shouldRefresh(): boolean { + if (!this._refresher || !this._expiresAt) return false; + const expires = Date.parse(this._expiresAt); + if (Number.isNaN(expires)) return false; + return (expires - Date.now()) / 1000 <= REFRESH_MARGIN_SECONDS; + } + + private async _maybeRefresh(): Promise { + if (!this._refresher || !this._shouldRefresh()) return; + const fresh = await this._refresher(); + this._browserUrl = fresh._browserUrl; + this._serviceUrl = fresh._serviceUrl; + this._token = fresh._token; + this._expiresAt = fresh._expiresAt; + } + + /** The signed token, refreshed if near expiry. */ + async token(): Promise { + await this._maybeRefresh(); + return this._token; + } + + /** The service base URL, refreshed if near expiry. */ + async serviceUrl(): Promise { + await this._maybeRefresh(); + return this._serviceUrl; + } + + /** The browser URL that authenticates then redirects, refreshed if near expiry. */ + async browserUrl(): Promise { + await this._maybeRefresh(); + return this._browserUrl; + } + + /** ISO 8601 expiry of the current token, refreshed if near expiry. */ + async expiresAt(): Promise { + await this._maybeRefresh(); + return this._expiresAt; + } + + /** Fetch a path on the service with the token header injected. */ + async fetch(path = "/", init: RequestInit = {}): Promise { + const base = (await this.serviceUrl()).replace(/\/+$/, ""); + const url = `${base}/${path.replace(/^\/+/, "")}`; + const headers = new Headers(init.headers); + headers.set(SERVICE_TOKEN_HEADER, await this.token()); + return fetch(url, { ...init, headers }); + } +} + +/** + * Service URL gated by LangSmith login rather than a token. + * + * The grant is durable: no token, no expiry, and only usable from a browser + * signed in to LangSmith — which is why there is no fetch helper here, a + * programmatic request cannot satisfy the login. + */ +export class ServiceLoginUrl { + /** The URL to open in a browser. */ + readonly url: string; + /** Who may open it: "restricted" or "workspace". */ + readonly access: string; + + constructor(data: ServiceUrlData) { + this.url = data.browser_url || data.service_url || ""; + this.access = data.access ?? ""; + } +} diff --git a/src/lib/sandbox/types.ts b/src/lib/sandbox/types.ts new file mode 100644 index 0000000..a56f229 --- /dev/null +++ b/src/lib/sandbox/types.ts @@ -0,0 +1,1005 @@ +/** + * TypeScript interfaces for the sandbox module. + * + * Field names use snake_case to match API response format. + */ + +import type { AccessDelegation } from "./access_delegation.js"; + +/** + * Result of executing a command in a sandbox. + */ +export interface ExecutionResult { + stdout: string; + stderr: string; + exit_code: number; +} + +/** + * Lightweight provisioning status for any async-created resource. + */ +export interface ResourceStatus { + /** One of "provisioning", "ready", "failed". */ + status: string; + /** Human-readable details when failed. */ + status_message?: string; +} + +/** + * The user, working directory and environment commands run with. + * + * Mirrors `docker run -u / -w / -e`: `user` and `work_dir` replace the layer + * below, `env_vars` merge into it key by key. It applies at three points, each + * layered over the one before -- the snapshot, the sandbox, and a single + * command. + */ +export interface RunConfig { + /** + * Account to run as: `name`, `uid`, `name:group` or `uid:gid`. Defaults to + * the Docker image's `USER`. + */ + user?: string; + /** + * Absolute working directory. Defaults to the image's `WORKDIR`. A relative + * path is rejected by the server. + */ + work_dir?: string; + /** Environment variables, merged over the layer below. */ + env_vars?: Record; +} + +/** + * One filesystem entry returned by `sandbox.glob()`. + */ +export interface FileInfo { + /** Absolute path of the entry. */ + path: string; + /** True for a directory. */ + is_dir: boolean; + /** Size in bytes. */ + size_bytes: number; + /** RFC 3339 modification timestamp. */ + modified_at?: string; +} + +/** + * Entries matching a glob pattern. + */ +export interface GlobResult { + matches: FileInfo[]; + /** + * True when the server hit its result cap or deadline, so the search is a + * partial answer worth refining. + */ + truncated: boolean; +} + +/** + * One matching line found by `sandbox.grep()`. + */ +export interface GrepMatch { + /** Absolute path of the file the match was found in. */ + path: string; + /** 1-based line number. */ + line: number; + /** The matching line's text. */ + text: string; +} + +/** + * Lines matching a literal search. + */ +export interface GrepResult { + matches: GrepMatch[]; + /** True when the server hit its result cap or deadline. */ + truncated: boolean; +} + +/** + * Options for the read-only filesystem search operations. + */ +export interface GlobOptions { + /** Maximum matches to return. */ + limit?: number; + /** Request timeout in seconds. */ + timeout?: number; + headers?: Record; +} + +/** + * Options for `sandbox.grep()`. + */ +export interface GrepOptions extends GlobOptions { + /** + * Restrict which files are searched. A bare `*.py` matches by basename at + * any depth; a pattern containing `/` or `**` matches the path relative to + * the search root. + */ + glob?: string; +} + +/** + * What a `HEAD` on a sandbox file reports, without transferring it. + */ +export interface FileStat { + /** The file's size. */ + size_bytes: number; + /** + * Strong validator for the current contents. Opaque -- compare it, never + * parse it. Pass it back as `ifRange` to resume a download safely, or as + * `ifNoneMatch` to poll for a change. + */ + etag?: string; + /** HTTP-date of the last modification, second-resolution. */ + last_modified?: string; + /** The server's content type for the file. */ + content_type?: string; +} + +/** + * Bytes returned by a ranged read, and where they sit in the file. + */ +export interface FileChunk { + /** The bytes returned. Empty when `unchanged` is true. */ + content: Uint8Array; + /** + * Validator for the version these bytes came from. Pass it as `ifRange` on + * the next chunk so a rewrite restarts the read instead of splicing two + * versions together. + */ + etag?: string; + /** The file's full size, or undefined when the server did not report it. */ + total_bytes?: number; + /** Offset of the first byte returned. */ + start: number; + /** + * True when the server answered 206 with only part of the file. A false + * here after a ranged request means the file changed and the server sent it + * whole -- restart from zero. + */ + partial: boolean; + /** + * True when the caller passed `ifNoneMatch` and the file still matches it. + * No bytes are returned. + */ + unchanged: boolean; + /** HTTP-date of the last modification. */ + last_modified?: string; +} + +/** + * Options for a ranged read. Provide `start` (with optional `end`), or + * `suffixBytes`. + */ +export interface ReadRangeOptions { + /** First byte to return. With `end`, an inclusive range. */ + start?: number; + /** Last byte to return, inclusive. Clamped to EOF rather than rejected. */ + end?: number; + /** Return the last N bytes. Mutually exclusive with `start`/`end`. */ + suffixBytes?: number; + /** + * An `ETag` from an earlier chunk. The range is honored only while the file + * still matches it. + */ + ifRange?: string; + /** + * An `ETag` the caller already holds. An unchanged file answers with + * `unchanged: true` and no bytes. + */ + ifNoneMatch?: string; + /** Request timeout in seconds. */ + timeout?: number; + headers?: Record; +} + +/** + * Represents a sandbox snapshot. + * + * Snapshots are built from Docker images or captured from running sandboxes. + * They are used to create new sandboxes. + */ +export interface Snapshot { + id: string; + name: string; + /** One of "building", "ready", "failed". */ + status: string; + fs_capacity_bytes: number; + docker_image?: string; + image_digest?: string; + source_sandbox_id?: string; + status_message?: string; + fs_used_bytes?: number; + created_by?: string; + registry_id?: string; + created_at?: string; + updated_at?: string; + /** + * User, working directory and environment sandboxes built from this + * snapshot boot with, resolved from the Docker image at build time. Absent + * on snapshots built before the server recorded it, which boot as root with + * no image environment. + */ + run_config?: RunConfig; +} + +/** + * Data representing a sandbox instance from the API. + */ +export interface SandboxData { + id?: string; + name: string; + dataplane_url?: string; + status?: string; + status_message?: string; + created_at?: string; + updated_at?: string; + /** + * Idle timeout in seconds. The launcher stops the sandbox after this + * many seconds of inactivity. `0` disables the idle stop; + * omitted/`undefined` means not set (the server applies a default). + */ + idle_ttl_seconds?: number; + /** + * Seconds after a sandbox enters the `stopped` state before it (and its + * filesystem clone) are permanently deleted. `0` disables stop-anchored + * deletion; omitted/`undefined` falls back to the server default. + */ + delete_after_stop_seconds?: number; + /** + * Timestamp when the sandbox transitioned to the `stopped` state, or + * `undefined` while running. The deletion deadline is + * `stopped_at + delete_after_stop_seconds`. + */ + stopped_at?: string; + /** Snapshot ID used to create this sandbox. */ + snapshot_id?: string; + /** Number of vCPUs allocated. */ + vcpus?: number; + /** Memory allocation in bytes. */ + mem_bytes?: number; + /** Root filesystem capacity in bytes. */ + fs_capacity_bytes?: number; + /** + * User, working directory and environment the sandbox's commands run with. + * Absent on sandboxes created before the server recorded it. + */ + run_config?: RunConfig; + /** + * LangSmith access granted to code inside the sandbox. Absent when it has + * none. + */ + access_delegation?: AccessDelegation; +} + +/** + * Configuration options for the SandboxClient. + */ +export interface SandboxClientConfig { + /** + * Full URL of the sandbox API endpoint. + * If not provided, derived from LANGSMITH_ENDPOINT environment variable. + */ + apiEndpoint?: string; + /** + * API key for authentication. + * If not provided, uses LANGSMITH_API_KEY environment variable. + */ + apiKey?: string; + /** + * Default HTTP timeout in milliseconds. + */ + timeoutMs?: number; + /** + * Maximum number of retries for transient failures (network errors, 5xx, 429). + * Defaults to 3. + */ + maxRetries?: number; + /** + * Maximum number of concurrent requests. + * Defaults to Infinity (no limit). + */ + maxConcurrency?: number; + /** + * Optional default headers attached to every request on this client, + * including the data-plane `/execute` HTTP endpoint and the `/execute/ws` + * WebSocket upgrade. Use this to pass additional auth headers + * (e.g. `X-Service-Key`). + */ + headers?: Record; +} + +/** + * A single chunk of streaming output from command execution. + */ +export interface OutputChunk { + /** Either "stdout" or "stderr". */ + stream: "stdout" | "stderr"; + /** The text content of this chunk. */ + data: string; + /** Byte offset within the stream. Used internally for reconnection. */ + offset: number; +} + +/** + * Internal WebSocket message type from the server. + */ +export interface WsMessage { + type: "started" | "stdout" | "stderr" | "exit" | "error"; + [key: string]: unknown; +} + +/** + * Options for the low-level WebSocket stream functions. + */ +export interface WsRunOptions { + /** Command timeout in seconds. Default: 60. */ + timeout?: number; + /** + * WebSocket connect timeout in seconds, overriding the module default. + * run() passes the remainder of its overall connect budget. @internal + */ + openTimeout?: number; + /** Environment variables to set for the command. */ + env?: Record; + /** Working directory for command execution. */ + cwd?: string; + /** Shell to use. Default: "/bin/bash". */ + shell?: string; + /** Callback invoked with each stdout chunk. */ + onStdout?: (data: string) => void; + /** Callback invoked with each stderr chunk. */ + onStderr?: (data: string) => void; + /** Client-assigned command ID. */ + commandId?: string; + /** + * Idle timeout in seconds. If the command has no connected clients for + * this duration, it is killed. Defaults to 300 (5 minutes). + * Set to -1 for no idle timeout. + */ + idleTimeout?: number; + /** + * If true, kill the command immediately when the last client disconnects. + * Defaults to false (command continues running and can be reconnected to). + */ + killOnDisconnect?: boolean; + /** + * How long (in seconds) a finished command's session is kept for + * reconnection. Defaults to 600 (10 minutes). Set to -1 to keep indefinitely. + */ + ttlSeconds?: number; + /** Whether to allocate a PTY. */ + pty?: boolean; + /** Per-command user, working directory and environment. */ + runConfig?: RunConfig; + /** Half-close the spawned process's stdin so it reads EOF. */ + closeStdin?: boolean; + /** + * Additional headers attached to the WebSocket upgrade request. Merged on + * top of any default headers the SandboxClient was constructed with. + */ + headers?: Record; +} + +/** + * Options for running a command in a sandbox. + */ +export interface RunOptions { + /** + * Command timeout in seconds. + */ + timeout?: number; + /** + * Environment variables to set for the command. + * + * @deprecated Use `runConfig.env_vars`. The two cannot be combined. + */ + env?: Record; + /** + * Working directory for command execution. + * + * @deprecated Use `runConfig.work_dir`. The two cannot be combined. + */ + cwd?: string; + /** + * User, working directory and environment for this one command, layered + * over the sandbox's own. + */ + runConfig?: RunConfig; + /** + * Half-close the command's stdin so a command that reads it sees EOF + * rather than blocking until the timeout. Defaults to true for a non-PTY + * command, which means `sendInput()` on the returned handle throws unless + * this is set to false. Ignored under `pty: true`, where input and output + * share one terminal file descriptor -- send an EOT byte (0x04) as input + * instead. + */ + closeInput?: boolean; + /** + * Shell to use for command execution. Defaults to "/bin/bash". + */ + shell?: string; + /** + * Whether to wait for the command to complete before returning. + * When true (default), returns an ExecutionResult. + * When false, returns a CommandHandle for streaming output. + */ + wait?: boolean; + /** + * Client-assigned command ID. Executing with an existing command ID + * re-attaches to that command (get-or-create) instead of starting a + * new one. Only used by the WebSocket path. + */ + commandId?: string; + /** + * Callback invoked with each stdout chunk during streaming execution. + * When provided, WebSocket streaming is used. + */ + onStdout?: (data: string) => void; + /** + * Callback invoked with each stderr chunk during streaming execution. + * When provided, WebSocket streaming is used. + */ + onStderr?: (data: string) => void; + /** + * Idle timeout in seconds. If the command has no connected clients for + * this duration, it is killed. Defaults to 300 (5 minutes). + * Set to -1 for no idle timeout. + */ + idleTimeout?: number; + /** + * If true, kill the command immediately when the last client disconnects. + * Defaults to false (command continues running and can be reconnected to). + */ + killOnDisconnect?: boolean; + /** + * How long (in seconds) a finished command's session is kept for + * reconnection. Defaults to 600 (10 minutes). Set to -1 to keep indefinitely. + */ + ttlSeconds?: number; + /** + * If true, allocate a pseudo-terminal (PTY) for the command. + * Useful for commands that require a TTY (e.g., interactive programs, + * commands that use terminal control codes). Defaults to false. + */ + pty?: boolean; +} + +/** + * Network access-control rules for a sandbox's proxy sidecar. + * + * Supported pattern types: exact domains, globs (e.g. `*.example.com`), + * IPs, CIDR ranges (e.g. `10.0.0.0/8`), and regex (`~pattern`). + * + * Only one of `allow_list` and `deny_list` may be populated. + */ +export interface SandboxAccessControl { + /** Hosts the sandbox is allowed to reach. */ + allow_list?: string[]; + /** Hosts the sandbox is blocked from reaching. */ + deny_list?: string[]; +} + +/** Secret value reference for sandbox proxy rules. */ +export interface SandboxProxySecret { + /** `workspace_secret` references a workspace secret; `opaque` is write-only. */ + type: "workspace_secret" | "opaque"; + /** Workspace secret reference or opaque secret value. */ + value: string; +} + +/** Static credentials used by the sandbox proxy signer. */ +interface AwsStaticAuthConfig { + access_key_id: SandboxProxySecret; + secret_access_key: SandboxProxySecret; + role_arn?: ""; +} + +/** IAM role assumed and renewed by LangSmith, outside the sandbox. */ +interface AwsRoleAuthConfig { + role_arn: string; + access_key_id?: never; + secret_access_key?: never; +} + +/** AWS auth rule for sandbox proxy SigV4 signing. */ +export interface SandboxAwsAuthRule< + Auth extends AwsStaticAuthConfig | AwsRoleAuthConfig = + | AwsStaticAuthConfig + | AwsRoleAuthConfig, +> { + /** Rule name. */ + name: string; + /** AWS auth rules are matched by the sandbox proxy's AWS endpoint matcher. */ + type: "aws"; + /** Whether the rule is enabled. */ + enabled?: boolean; + /** + * Plaintext environment variables set for every command in the sandbox while + * the rule is enabled, for tools that refuse to run unless a credential + * variable is present even though the proxy injects the real credential on + * the wire. + */ + env_vars?: Record; + /** Exclusive static credentials or IAM-role descriptor. */ + aws: Auth; +} + +/** GCP auth rule for sandbox proxy OAuth bearer injection. */ +export interface SandboxGcpAuthRule { + /** Rule name. */ + name: string; + /** GCP auth rules use the sandbox proxy's built-in Google API host matcher. */ + type: "gcp"; + /** Whether the rule is enabled. */ + enabled?: boolean; + /** + * Plaintext environment variables set for every command in the sandbox while + * the rule is enabled, for tools that refuse to run unless a credential + * variable is present even though the proxy injects the real credential on + * the wire. + */ + env_vars?: Record; + /** GCP service-account credential and OAuth scopes. */ + gcp: { + service_account_json: SandboxProxySecret; + scopes?: string[]; + }; +} + +/** Proxy rule accepted by the sandbox proxy config. */ +export type SandboxProxyRule = + | SandboxAwsAuthRule + | SandboxGcpAuthRule + | Record; + +/** + * Full proxy configuration forwarded to the sandbox server as-is (snake_case + * so it's wire-compatible with the backend). Mirrors the server's + * `ProxyConfig` type. + */ +export interface SandboxProxyConfig { + /** Header-injection rules keyed by host pattern. */ + rules?: SandboxProxyRule[]; + /** @deprecated Ignored by the server. The sandbox runtime has no proxy bypass list. */ + no_proxy?: string[]; + /** Allow/deny list enforced at the proxy sidecar. */ + access_control?: SandboxAccessControl; +} + +/** Optional per-mount cache configuration supported by bucket mounts. */ +export interface MountCacheConfig { + /** Maximum VFS cache size in bytes. */ + max_size_bytes?: number; + /** Seconds rclone waits before writing cached changes back. */ + writeback_seconds?: number; +} + +interface SandboxBucketMountBase { + /** Stable mount identifier. */ + id: string; + /** Absolute path inside the sandbox where the mount appears. */ + mount_path: string; + /** Whether the mount should be read-only. */ + read_only?: boolean; + /** Optional per-mount VFS cache configuration. */ + cache?: MountCacheConfig; +} + +/** + * S3 configuration for a sandbox mount. Field names are snake_case so the + * object is wire-compatible with the backend `MountSpec` type. + */ +export interface S3MountConfig { + /** S3 or S3-compatible endpoint URL. */ + endpoint_url: string; + /** AWS region for signing and bucket access. */ + region: string; + /** Bucket name. */ + bucket: string; + /** Optional key prefix inside the bucket. */ + prefix?: string; + /** Whether to use path-style addressing for S3-compatible endpoints. */ + path_style?: boolean; +} + +/** S3-backed sandbox mount specification. */ +export interface S3MountSpec extends SandboxBucketMountBase { + /** Mount type. */ + type: "s3"; + /** S3 mount configuration. */ + s3: S3MountConfig; +} + +/** GCS configuration for a sandbox mount. */ +export interface GCSMountConfig { + /** GCS bucket name. */ + bucket: string; + /** Optional object prefix inside the bucket. */ + prefix?: string; +} + +/** GCS-backed sandbox mount specification. */ +export interface GCSMountSpec extends SandboxBucketMountBase { + /** Mount type. */ + type: "gcs"; + /** GCS mount configuration. */ + gcs: GCSMountConfig; +} + +/** Git ref selected for a sandbox mount. */ +export interface GitMountRefSpec { + /** Git ref type. */ + type: "branch" | "tag"; + /** Branch or tag name. */ + name: string; +} + +/** Git configuration for a sandbox mount. */ +export interface GitMountConfig { + /** Public HTTPS Git remote URL. */ + remote_url: string; + /** Optional branch or tag to mount. */ + ref?: GitMountRefSpec; + /** Optional refresh interval for polling the remote. */ + refresh_interval_seconds?: number; +} + +/** Git-backed sandbox mount specification. */ +export interface GitMountSpec { + /** Stable mount identifier. */ + id: string; + /** Mount type. */ + type: "git"; + /** Absolute path inside the sandbox where the mount appears. */ + mount_path: string; + /** Git mount configuration. */ + git: GitMountConfig; +} + +/** Context Hub configuration for a sandbox mount. */ +export interface ContextHubMountConfig { + /** + * Context Hub repository to sync, as `owner/repo` (e.g. `-/my-agent`, where + * `-` is the current workspace). + */ + repo: string; + /** Sync once at startup instead of polling for updates. */ + initial_pull_only?: boolean; +} + +/** Read-only Context Hub-backed sandbox mount specification. */ +export interface ContextHubMountSpec { + /** Stable mount identifier. */ + id: string; + /** Mount type. */ + type: "contexthub"; + /** Absolute path inside the sandbox where the repo tree appears. */ + mount_path: string; + /** Context Hub mount configuration. */ + contexthub: ContextHubMountConfig; +} + +/** Sandbox mount specification. */ +export type SandboxMount = + | S3MountSpec + | GCSMountSpec + | GitMountSpec + | ContextHubMountSpec; + +/** AWS credentials used by the backend to authenticate S3 mounts. */ +export interface SandboxAwsMountAuthConfig { + access_key_id: SandboxProxySecret; + secret_access_key: SandboxProxySecret; + role_arn?: ""; +} + +/** GCP credentials used by the backend to authenticate GCS mounts. */ +export interface SandboxGcpMountAuthConfig { + service_account_json: SandboxProxySecret; +} + +/** Provider auth blocks for sandbox mounts. */ +export interface SandboxMountAuthConfig { + /** IAM roles here are restricted to this sandbox's S3 mount scopes. */ + aws?: SandboxAwsMountAuthConfig | AwsRoleAuthConfig; + gcp?: SandboxGcpMountAuthConfig; +} + +/** Provider auth helper output accepted by mountConfig. */ +export type SandboxMountAuth = SandboxAwsAuthRule | SandboxGcpAuthRule; + +/** Public mount config sent to the sandbox API. */ +export interface SandboxMountConfig { + /** Provider auth blocks for bucket-backed mounts. */ + auth: SandboxMountAuthConfig; + /** Mounts attached to the sandbox. */ + mounts: SandboxMount[]; +} + +/** + * Options for creating a sandbox. + */ +export interface CreateSandboxOptions { + /** + * Optional snapshot name to boot from. Mutually exclusive with the positional + * `snapshotId`. + * Resolved server-side to a snapshot owned by the caller's tenant. + */ + snapshotName?: string; + /** + * Optional sandbox name (auto-generated if not provided). + */ + name?: string; + /** + * Timeout in seconds when waiting for ready. + */ + timeout?: number; + /** + * Whether to wait for the sandbox to be ready before returning. + * When false, returns immediately with status "provisioning". + * Use getSandboxStatus() or waitForSandbox() to poll for readiness. + * Default: true. + */ + waitForReady?: boolean; + /** + * Idle timeout in seconds. The launcher stops the sandbox after this many + * seconds of inactivity. Must be a multiple of 60. Pass `0` to disable + * the idle stop. When omitted, the server applies a default of `600` + * seconds (10 minutes). + */ + idleTtlSeconds?: number; + /** + * Seconds after the sandbox enters the `stopped` state before it (and + * its filesystem clone) are permanently deleted. Must be a multiple of + * 60. Pass `0` to disable stop-anchored deletion (manual cleanup + * required). When omitted, the server applies its configured default + * (typically 14 days). + */ + deleteAfterStopSeconds?: number; + /** Number of vCPUs. */ + vCpus?: number; + /** Memory in bytes. */ + memBytes?: number; + /** Root filesystem capacity in bytes. */ + fsCapacityBytes?: number; + /** + * Mount configuration forwarded to the server as `mount_config`. The backend + * expands mount auth into runtime proxy rules. Explicit AWS/GCP proxy rules + * in `proxyConfig` conflict with mount auth for the same provider. + */ + mountConfig?: SandboxMountConfig; + /** + * Per-sandbox proxy configuration. Use + * `{ access_control: { allow_list: ["github.com", "*.example.com"] } }` + * to restrict outbound HTTPS to a set of host patterns. Forwarded to the + * server as-is on the wire. Use `proxyConfig` with provider rule helpers + * such as `awsAuth` for AWS SigV4 auth or `gcpAuth` for GCP OAuth bearer + * auth. + */ + proxyConfig?: SandboxProxyConfig; + /** + * User, working directory and environment the sandbox boots with, + * overriding the snapshot's: `user` and `work_dir` replace, `env_vars` + * merge. The sandbox's own env vars remain a layer above this one. + */ + runConfig?: RunConfig; + /** + * Grant letting code inside the sandbox call the LangSmith API as you, with + * no API key of its own. `{ mode: "INHERIT" }` grants everything you can do; + * `{ mode: "EXPLICIT", permissions: [...] }` grants only the permissions + * listed, each of which you must already hold. The grant belongs to the + * sandbox, so anyone who can exec into it can make calls under it. Omit for + * no access. + */ + accessDelegation?: AccessDelegation; +} + +/** + * Options for creating a snapshot from a Docker image. + */ +export interface CreateSnapshotOptions { + /** Private registry ID. */ + registryId?: string; + /** + * Override the Docker image's `USER`, `WORKDIR` and `ENV` for sandboxes + * built from this snapshot: `user` and `work_dir` replace the image's, + * `env_vars` merge over it key by key. Omitting it adopts the image's own + * configuration. Pass `{ user: "0" }` to keep running as root. + */ + runConfig?: RunConfig; + /** Timeout in seconds when waiting for ready. Default: 60. */ + timeout?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * Options for creating a snapshot from a local Dockerfile context. + */ +export interface CreateDockerfileSnapshotOptions { + /** Local Docker build context directory. Default: current working directory. */ + context?: string; + /** Docker build args passed as BuildKit build-arg opts. */ + buildArgs?: Record; + /** Optional Dockerfile target stage. */ + target?: string; + /** Callback for Docker build stdout/stderr chunks. */ + onBuildLog?: (data: string) => void; + /** + * Number of vCPUs for the temporary builder sandbox. The build runs + * BuildKit plus the native snapshotter's layer copies inside it, which + * contend for a single core by default, so an extra vCPU can cut a cold + * build's wall time substantially. + */ + vCpus?: number; + /** Memory in bytes for the temporary builder sandbox. */ + memBytes?: number; + /** Timeout in seconds for builder sandbox operations. Default: 60. */ + timeout?: number; +} + +/** + * Options for capturing a snapshot from a running sandbox. + */ +export interface CaptureSnapshotOptions { + /** + * Docker image tag inside the sandbox to export into the snapshot instead + * of capturing the live root filesystem. + */ + dockerImage?: string; + /** Filesystem capacity in bytes for Docker image export. */ + fsCapacityBytes?: number; + /** + * Override applied over the configuration the captured sandbox was running + * with: `user` and `work_dir` replace, `env_vars` merge. + */ + runConfig?: RunConfig; + /** Timeout in seconds when waiting for ready. Default: 60. */ + timeout?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * How a download link asks the browser to handle the file. + */ +export type DownloadContentDisposition = "attachment" | "inline"; + +/** + * Options for minting a sandbox file download link. + */ +export interface GenerateDownloadURLOptions { + /** + * Link TTL in seconds. Omit for a link that never expires. + */ + expiresInSeconds?: number; + /** Content-Type to serve the file as. */ + contentType?: string; + /** Content-Disposition to serve the file with. */ + contentDisposition?: DownloadContentDisposition; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * A link that downloads one sandbox file with no LangSmith credential. + * + * The link is pinned to the sandbox, the file path, and the response headers, + * so it cannot be repointed at another file. It is pinned to the path rather + * than to a snapshot of the contents, so the file must not be modified while + * the link is in use. + */ +export interface DownloadURL { + /** The full URL to fetch. Supports GET, HEAD, and Range. */ + download_url: string; + /** The signed token embedded in `download_url`. */ + token: string; + /** Expiry timestamp, or null for a link that never expires. */ + expires_at: string | null; +} + +/** + * Options for listing snapshots. All fields are optional and independent. + * + * The backend always paginates: when `limit` is omitted the server applies + * a default page size (currently 50), so a single call will not necessarily + * return every snapshot visible to the caller's tenant. + */ +export interface ListSnapshotsOptions { + /** + * Case-insensitive substring filter applied server-side to snapshot + * names. + */ + nameContains?: string; + /** + * Maximum number of snapshots to return for a single request. Must be + * between 1 and 500 (inclusive); the server rejects values outside that + * range. Defaults to 50 server-side when omitted. + */ + limit?: number; + /** + * Number of snapshots to skip before returning results. Must be `>= 0`. + * Useful for paginating through large result sets in combination with + * `limit`. + */ + offset?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * Options for waiting for a snapshot to become ready. + */ +export interface WaitForSnapshotOptions { + /** Maximum time in seconds to wait. Default: 300. */ + timeout?: number; + /** Time in seconds between status polls. Default: 2.0. */ + pollInterval?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * Options for starting a stopped sandbox. + */ +export interface StartSandboxOptions { + /** Timeout in seconds when waiting for ready. Default: 120. */ + timeout?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} + +/** + * Options for updating a sandbox (name, retention settings, proxy config). + */ +export interface UpdateSandboxOptions { + /** New display name. */ + newName?: string; + /** + * Idle timeout in seconds. Must be a multiple of 60. Pass `0` to disable + * the idle stop. Omit (or pass `undefined`) to leave the existing value + * unchanged. + */ + idleTtlSeconds?: number; + /** + * Seconds after entering `stopped` before deletion. Must be a multiple + * of 60. Pass `0` to disable stop-anchored deletion. Omit (or pass + * `undefined`) to leave the existing value unchanged. + */ + deleteAfterStopSeconds?: number; + /** + * Replacement proxy configuration, sent to the server as-is (same shape as + * `createSandbox`). Rules replace the existing set rather than merging into + * it, so include every rule the sandbox should keep. Opaque header values + * carry over from the current config, so rotating one credential does not + * mean re-supplying secrets that can no longer be read. The sandbox must be + * `ready`; start a stopped one first. + */ + proxyConfig?: SandboxProxyConfig; + /** + * Merge into the sandbox's stored run configuration: `user` and `work_dir` + * replace, `env_vars` merge. Takes effect for subsequent commands; commands + * already running are unaffected, and a stopped sandbox picks it up on its + * next start. Omitting it leaves the stored value untouched. + */ + runConfig?: RunConfig; +} + +/** + * Options for waiting for a sandbox to become ready. + */ +export interface WaitForSandboxOptions { + /** + * Maximum time in seconds to wait for the sandbox to become ready. + * Default: 120. + */ + timeout?: number; + /** + * Time in seconds between status polls. + * Default: 1.0. + */ + pollInterval?: number; + /** AbortSignal for cancellation. */ + signal?: AbortSignal; +} diff --git a/src/lib/sandbox/verify.ts b/src/lib/sandbox/verify.ts new file mode 100644 index 0000000..675f6a5 --- /dev/null +++ b/src/lib/sandbox/verify.ts @@ -0,0 +1,407 @@ +import { getLangSmithEnvironmentVariable } from "../utils/env.js"; +import { LangSmithSandboxTokenVerificationError } from "./errors.js"; + +/** Header carrying the signed identity of a LangSmith-login service URL request. */ +export const USER_TOKEN_HEADER = "X-Langsmith-User-Token"; + +/** Header carrying the signature of a proxy callback request. */ +export const CALLBACK_SIGNATURE_HEADER = "X-LangSmith-Signature-JWT"; + +const CALLBACK_SUBJECT = "langsmith-sandbox-callback"; +const JWKS_PATH = "/.well-known/jwks.json"; +const JWKS_TTL_MS = 300_000; +// Bounds refetches when a token names a kid the cached set lacks. +const JWKS_MIN_REFRESH_MS = 30_000; +const LEEWAY_SECONDS = 30; + +/** The LangSmith user a service URL request was made by. */ +export interface SandboxUser { + subject: string; + email?: string; + name?: string; + expiresAt: Date; +} + +/** The sandbox whose outbound request triggered a proxy callback. */ +export interface SandboxCallbackIdentity { + tenant_id: string; + sandbox_id: string; + organization_id?: string; + ls_user_id?: string; +} + +/** Snapshot of the outbound request, sent for `full_request` callbacks. */ +export interface SandboxCallbackRequest { + method: string; + url: string; + scheme: string; + host: string; + path: string; + query?: string; + headers: Record; + body_base64: string; + body_truncated: boolean; +} + +/** A verified proxy callback payload. */ +export interface SandboxCallback { + host: string; + port: number; + identity: SandboxCallbackIdentity; + request?: SandboxCallbackRequest; +} + +export interface SandboxTokenVerifierConfig { + /** LangSmith API URL whose origin serves the JWKS. Defaults to LANGSMITH_ENDPOINT. */ + apiUrl?: string; + /** Full JWKS URL; overrides `apiUrl`. */ + jwksUrl?: string; + /** Timeout in milliseconds for fetching the JWKS. */ + timeoutMs?: number; + /** + * Allow fetching the JWKS over plain HTTP from a non-loopback host. Anyone + * who can tamper with that traffic can forge tokens this verifier accepts. + */ + allowInsecureJwks?: boolean; +} + +export interface VerifyUserTokenOptions { + /** + * The service URL host the request was sent to, such as the request's + * `Host` header. A full service URL is also accepted. + */ + audience: string; + /** If set, the LangSmith app URL the token must be issued by. */ + issuer?: string; +} + +export interface VerifyCallbackOptions { + /** The raw request body, exactly as received. */ + body: string | Uint8Array | ArrayBuffer; + /** The `X-LangSmith-Signature-JWT` header value. */ + signature: string; + /** + * If set, checks the signature's audience, which is the callback URL as + * configured in the proxy config. A string must match exactly; a predicate + * is called with each audience and must return true for at least one. + */ + aud?: AudienceMatcher; + /** If set, the LangSmith OAuth issuer the signature must be issued by. */ + issuer?: string; +} + +type Claims = Record; + +/** An exact audience, or a predicate called with each audience in the token. */ +export type AudienceMatcher = string | ((aud: string) => boolean); + +interface Jwk { + kty?: string; + crv?: string; + kid?: string; + x?: string; +} + +function fail(message: string): never { + throw new LangSmithSandboxTokenVerificationError(message); +} + +function subtle(): SubtleCrypto { + const s = globalThis.crypto?.subtle; + if (!s) fail("Web Crypto is not available in this runtime"); + return s; +} + +function base64UrlDecode(value: string): Uint8Array { + const b64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const bin = atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +function decodeJson(segment: string): Claims { + const value = JSON.parse(new TextDecoder().decode(base64UrlDecode(segment))); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error("not a JSON object"); + } + return value as Claims; +} + +function isLoopback(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname === "[::1]" || + /^127(\.\d{1,3}){3}$/.test(hostname) + ); +} + +function checkJwksUrl(url: string, allowInsecure: boolean): void { + const { protocol, hostname } = new URL(url); + if (protocol === "https:" || allowInsecure) return; + if (protocol === "http:" && isLoopback(hostname)) return; + throw new Error( + `JWKS URL must use https, got ${JSON.stringify(url)}; pass ` + + "allowInsecureJwks: true only if the network path to LangSmith is trusted", + ); +} + +function jwksUrlFrom(apiUrl: string): string { + return new URL(JWKS_PATH, new URL(apiUrl).origin).toString(); +} + +function serviceHost(audience: string): string { + return audience.includes("://") ? new URL(audience).host : audience; +} + +function toBytes( + body: string | Uint8Array | ArrayBuffer, +): Uint8Array { + if (typeof body === "string") return new TextEncoder().encode(body); + if (body instanceof ArrayBuffer) return new Uint8Array(body); + return new Uint8Array(body); +} + +function hex(buf: ArrayBuffer): string { + return Array.from(new Uint8Array(buf), (b) => + b.toString(16).padStart(2, "0"), + ).join(""); +} + +function timingSafeEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} + +/** + * Verifies tokens LangSmith signs for code running in or behind a sandbox. + * + * Keys are fetched from LangSmith's JWKS endpoint and cached. Requires Web + * Crypto Ed25519 support (Node.js 20+, Deno, Bun, Cloudflare Workers). + * + * @example + * ```typescript + * const verifier = new SandboxTokenVerifier(); + * + * // In an app served from a LangSmith-login service URL: + * const user = await verifier.verifyUserToken( + * req.headers.get(USER_TOKEN_HEADER)!, + * { audience: req.headers.get("host")! } + * ); + * + * // In a proxy callback endpoint: + * const callback = await verifier.verifyCallback({ + * body: await req.text(), + * signature: req.headers.get(CALLBACK_SIGNATURE_HEADER)!, + * aud: "https://example.com/sandbox-callback", + * }); + * ``` + */ +export class SandboxTokenVerifier { + private readonly jwksUrl: string; + private readonly timeoutMs: number; + private keys = new Map>(); + private fetchedAt = 0; + private inflight?: Promise; + + constructor(config: SandboxTokenVerifierConfig = {}) { + this.jwksUrl = + config.jwksUrl ?? + jwksUrlFrom( + config.apiUrl ?? + getLangSmithEnvironmentVariable("ENDPOINT") ?? + "https://api.smith.langchain.com", + ); + checkJwksUrl(this.jwksUrl, config.allowInsecureJwks ?? false); + this.timeoutMs = config.timeoutMs ?? 10_000; + } + + /** + * Verify the `X-Langsmith-User-Token` header of a service URL request. + * + * LangSmith sets this header only for service URLs that use LangSmith login. + * Code that knows it is running in a sandbox can instead trust the unsigned + * `X-Langsmith-User-Id` and `X-Langsmith-User-Email` headers, which the + * sandbox runtime strips from inbound requests and sets itself; verify this + * token when that is not guaranteed. + */ + async verifyUserToken( + token: string, + options: VerifyUserTokenOptions, + ): Promise { + const claims = await this.verify( + token, + serviceHost(options.audience), + options.issuer, + ); + const sub = claims.sub; + if (typeof sub !== "string" || !sub || sub === CALLBACK_SUBJECT) { + fail("token is not a user token"); + } + return { + subject: sub, + email: + typeof claims.email === "string" && claims.email + ? claims.email + : undefined, + name: + typeof claims.name === "string" && claims.name + ? claims.name + : undefined, + expiresAt: new Date((claims.exp as number) * 1000), + }; + } + + /** Verify a proxy callback request and return its parsed payload. */ + async verifyCallback( + options: VerifyCallbackOptions, + ): Promise { + const claims = await this.verify( + options.signature, + options.aud, + options.issuer, + ); + if (claims.sub !== CALLBACK_SUBJECT) { + fail("signature is not a callback signature"); + } + const expected = claims.body_sha256; + if (typeof expected !== "string") fail("signature has no body hash"); + const raw = toBytes(options.body); + const digest = hex(await subtle().digest("SHA-256", raw)); + if (!timingSafeEqual(expected, digest)) + fail("body does not match signature"); + + let payload: SandboxCallback; + try { + payload = JSON.parse(new TextDecoder().decode(raw)); + } catch (e) { + fail(`malformed callback body: ${(e as Error).message}`); + } + const id = payload?.identity; + if ( + typeof payload?.host !== "string" || + typeof payload.port !== "number" || + typeof id?.tenant_id !== "string" || + typeof id.sandbox_id !== "string" + ) { + fail("malformed callback body"); + } + return payload; + } + + private async verify( + token: string, + audience: AudienceMatcher | undefined, + issuer: string | undefined, + ): Promise { + const parts = token ? token.split(".") : []; + if (parts.length !== 3) fail("malformed token"); + let header: Claims; + let claims: Claims; + let signature: Uint8Array; + try { + header = decodeJson(parts[0]); + claims = decodeJson(parts[1]); + signature = base64UrlDecode(parts[2]); + } catch (e) { + fail(`malformed token: ${(e as Error).message}`); + } + if (header.alg !== "EdDSA") { + fail(`unexpected signing algorithm: ${JSON.stringify(header.alg)}`); + } + if (typeof header.kid !== "string" || !header.kid) fail("token has no kid"); + + const key = await this.key(header.kid); + const signed = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); + if (!(await subtle().verify({ name: "Ed25519" }, key, signature, signed))) { + fail("invalid signature"); + } + + const now = Date.now() / 1000; + const { exp, iat, nbf, iss, aud } = claims; + if (typeof exp !== "number") fail("token has no exp"); + if (now > exp + LEEWAY_SECONDS) fail("token is expired"); + if (typeof iat !== "number") fail("token has no iat"); + if (iat > now + LEEWAY_SECONDS) fail("token is not yet valid"); + if ( + nbf !== undefined && + (typeof nbf !== "number" || nbf > now + LEEWAY_SECONDS) + ) { + fail("token is not yet valid"); + } + if (typeof iss !== "string") fail("token has no iss"); + if (issuer !== undefined && iss !== issuer.replace(/\/+$/, "")) { + fail("token has the wrong issuer"); + } + const audiences = + typeof aud === "string" ? [aud] : Array.isArray(aud) ? aud : []; + const matches = + typeof audience === "function" + ? audiences.some((a) => typeof a === "string" && audience(a)) + : audiences.includes(audience); + if (audience !== undefined && !matches) { + fail("token has the wrong audience"); + } + return claims; + } + + private async key(kid: string): Promise { + const age = Date.now() - this.fetchedAt; + const fresh = this.keys.has(kid) && age < JWKS_TTL_MS; + if (!fresh && (this.fetchedAt === 0 || age >= JWKS_MIN_REFRESH_MS)) { + this.inflight ??= this.refresh().finally(() => { + this.inflight = undefined; + }); + await this.inflight; + } + const key = this.keys.get(kid); + if (!key) fail(`unknown signing key: ${kid}`); + try { + return await key; + } catch (e) { + fail( + `signing key ${kid} is not a usable Ed25519 key: ${ + (e as Error).message + }`, + ); + } + } + + private async refresh(): Promise { + let body: unknown; + try { + const resp = await fetch(this.jwksUrl, { + signal: AbortSignal.timeout(this.timeoutMs), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + body = await resp.json(); + } catch (e) { + fail( + `failed to fetch JWKS from ${this.jwksUrl}: ${(e as Error).message}`, + ); + } + const entries = (body as { keys?: unknown } | null)?.keys; + if (!Array.isArray(entries)) { + fail("invalid JWKS: expected an object with a keys array"); + } + const keys = new Map>(); + for (const jwk of entries as (Jwk | null)[]) { + if (typeof jwk !== "object" || jwk === null) continue; + if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.kid || !jwk.x) + continue; + const imported = subtle().importKey( + "jwk", + { kty: "OKP", crv: "Ed25519", x: jwk.x }, + { name: "Ed25519" }, + false, + ["verify"], + ); + imported.catch(() => undefined); + keys.set(jwk.kid, imported); + } + this.keys = keys; + this.fetchedAt = Date.now(); + } +} diff --git a/src/lib/sandbox/ws_execute.ts b/src/lib/sandbox/ws_execute.ts new file mode 100644 index 0000000..1a5b31e --- /dev/null +++ b/src/lib/sandbox/ws_execute.ts @@ -0,0 +1,585 @@ +/** + * WebSocket-based command execution for long-running commands. + * + * Uses the `ws` npm package (optional peer dependency). + * Install with: npm install ws + */ + +import { + LangSmithCommandTimeoutError, + LangSmithSandboxConnectionError, + LangSmithSandboxConnectTimeoutError, + LangSmithSandboxOperationError, + LangSmithSandboxRetryableConnectionError, + LangSmithSandboxServerReloadError, +} from "./errors.js"; +import type { WsMessage, WsRunOptions } from "./types.js"; +import { getLangSmithEnvironmentVariable } from "../utils/env.js"; + +/** Read a WebSocket timeout override, in seconds. `<= 0` disables it. */ +function envTimeout(name: string, defaultSeconds: number): number | undefined { + const raw = getLangSmithEnvironmentVariable(name); + if (raw === undefined || raw.trim() === "") return defaultSeconds; + const value = Number(raw); + if (!Number.isFinite(value)) { + console.warn( + `Ignoring invalid LANGSMITH_${name}=${raw}, using ${defaultSeconds}`, + ); + return defaultSeconds; + } + return value > 0 ? value : undefined; +} + +// A slow cold start is recovered by the retry loop in run(), not by waiting here. +export const WS_OPEN_TIMEOUT = envTimeout("SANDBOX_WS_TIMEOUT_OPEN", 30); +// Ceiling on the whole connect phase. Without it, retrying a blackholed +// handshake costs MAX_AUTO_RECONNECTS + 1 full open timeouts plus backoff. +export const WS_CONNECT_BUDGET = envTimeout( + "SANDBOX_WS_TIMEOUT_CONNECT_BUDGET", + 120, +); +const RETRYABLE_HANDSHAKE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); + +function retryAfterSeconds( + headers: Record | undefined, +): number | undefined { + const raw = headers?.["retry-after"]; + if (typeof raw !== "string" || raw.trim() === "") return undefined; + const value = Number(raw); + return Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function nowSeconds(): number { + return performance.now() / 1000; +} + +/** Instant after which connect attempts must stop, if bounded. */ +export function connectDeadline(): number | undefined { + return WS_CONNECT_BUDGET === undefined + ? undefined + : nowSeconds() + WS_CONNECT_BUDGET; +} + +/** Seconds left in the connect budget, or undefined when unbounded. */ +export function remainingBudget( + deadline: number | undefined, +): number | undefined { + return deadline === undefined ? undefined : deadline - nowSeconds(); +} + +/** Per-attempt open timeout, clamped so it cannot outlive `deadline`. */ +export function openTimeoutFor( + deadline: number | undefined, +): number | undefined { + if (deadline === undefined) return WS_OPEN_TIMEOUT; + const remaining = Math.max(deadline - nowSeconds(), 0); + return WS_OPEN_TIMEOUT === undefined + ? remaining + : Math.min(WS_OPEN_TIMEOUT, remaining); +} + +// ============================================================================= +// Lazy ws import (optional peer dependency) +// ============================================================================= + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type WsWebSocket = any; + +// Load the optional 'ws' package once, at module load, instead of importing on +// every call. Resolves to the WebSocket constructor, or null if 'ws' isn't +// installed. run() reads isWsAvailable() to choose WS vs the HTTP fallback. +const wsWebSocketPromise: Promise = import("ws").then( + (ws) => ws.default || ws.WebSocket || ws, + () => null, +); + +/** Whether the optional `ws` package could be loaded (resolved once). */ +export async function isWsAvailable(): Promise { + return (await wsWebSocketPromise) !== null; +} + +async function ensureWs(): Promise<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + WebSocket: any; +}> { + const WebSocket = await wsWebSocketPromise; + if (!WebSocket) { + throw new Error( + "WebSocket-based execution requires the 'ws' package. " + + "Install it with: npm install ws", + ); + } + return { WebSocket }; +} + +// ============================================================================= +// URL and Auth Helpers +// ============================================================================= + +/** + * Convert a dataplane HTTP URL to a WebSocket URL for /execute/ws. + */ +export function buildWsUrl(dataplaneUrl: string): string { + const wsUrl = dataplaneUrl + .replace("https://", "wss://") + .replace("http://", "ws://"); + return `${wsUrl}/execute/ws`; +} + +/** + * Build auth headers for the WebSocket upgrade request. + */ +export function buildAuthHeaders( + apiKey: string | undefined, + extraHeaders?: Record, +): Record { + const headers: Record = {}; + if (apiKey) { + headers["X-Api-Key"] = apiKey; + } + if (extraHeaders) { + Object.assign(headers, extraHeaders); + } + return headers; +} + +// ============================================================================= +// Stream Control +// ============================================================================= + +/** + * Control interface for an active WebSocket stream. + * + * Created before the async generator starts, bound to the WebSocket once + * the connection opens. The CommandHandle holds a reference to this + * object to send kill/input messages. + */ +export class WSStreamControl { + private _ws: WsWebSocket = null; + private _closed = false; + private _killed = false; + private _closeStdinPending = false; + + /** Bind to the active WebSocket. Called inside the generator. */ + _bind(ws: WsWebSocket): void { + this._ws = ws; + if (this._closeStdinPending) { + this._closeStdinPending = false; + this.sendCloseStdin(); + } + } + + /** Mark as closed. Called when the generator exits. */ + _unbind(): void { + this._closed = true; + this._ws = null; + } + + /** True if kill() has been called on this stream. */ + get killed(): boolean { + return this._killed; + } + + /** Send a kill message to abort the running command. */ + sendKill(): void { + this._killed = true; + if (this._ws && !this._closed && this._ws.readyState === 1) { + this._ws.send(JSON.stringify({ type: "kill" })); + } + } + + /** Send stdin data to the running command. */ + sendInput(data: string): void { + if (this._ws && !this._closed && this._ws.readyState === 1) { + this._ws.send(JSON.stringify({ type: "input", data })); + } + } + + /** + * Half-close the command's stdin so it reads EOF. + * + * A reconnected stream binds its socket only once iteration starts, so a + * close arriving before that is queued and sent on bind — dropping it would + * leave the command waiting for an EOF that never comes. + */ + sendCloseStdin(): void { + if (this._closed) return; + if (this._ws && this._ws.readyState === 1) { + this._ws.send(JSON.stringify({ type: "close_stdin" })); + return; + } + this._closeStdinPending = true; + } +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +/** + * Raise the appropriate exception from a server error message. + */ +export function raiseForWsError(msg: WsMessage, commandId = ""): never { + const errorType = (msg.error_type as string | undefined) ?? "CommandError"; + const errorMsg = (msg.error as string | undefined) ?? "Unknown error"; + + if (errorType === "CommandTimeout") { + throw new LangSmithCommandTimeoutError(errorMsg); + } + if (errorType === "CommandNotFound") { + throw new LangSmithSandboxOperationError( + commandId ? `Command not found: ${commandId}` : errorMsg, + commandId ? "reconnect" : "command", + errorType, + ); + } + if (errorType === "SessionExpired") { + throw new LangSmithSandboxOperationError( + commandId ? `Session expired: ${commandId}` : errorMsg, + commandId ? "reconnect" : "command", + errorType, + ); + } + + throw new LangSmithSandboxOperationError( + errorMsg, + commandId ? "reconnect" : "command", + errorType, + ); +} + +// ============================================================================= +// WebSocket Stream Helpers +// ============================================================================= + +/** + * Create a ws WebSocket connection and return a promise that resolves when open + * or rejects on error. + * + * Socket-level failures and transient 5xx responses are retryable. Other + * non-101 responses are permanent connection errors. + */ +async function connectWs( + url: string, + headers: Record, + openTimeout: number | undefined = WS_OPEN_TIMEOUT, +): Promise { + const { WebSocket: WS } = await ensureWs(); + return new Promise((resolve, reject) => { + const ws = new WS(url, { + headers, + handshakeTimeout: + openTimeout === undefined ? undefined : openTimeout * 1000, + }); + let settled = false; + + ws.on("open", () => { + if (settled) return; + settled = true; + resolve(ws); + }); + + // Attaching this suppresses ws's own "error" for a non-101 response, so the + // rejected upgrade cannot be misreported as a retryable socket failure. ws + // leaves cleanup to the handler once a listener is present. + ws.on( + "unexpected-response", + ( + req: { destroy?: () => void }, + res: { + statusCode?: number; + headers?: Record; + resume?: () => void; + }, + ) => { + res.resume?.(); + req.destroy?.(); + if (settled) return; + settled = true; + const message = `WebSocket upgrade to ${url} rejected by server (HTTP ${res.statusCode})`; + if (RETRYABLE_HANDSHAKE_STATUS_CODES.has(res.statusCode ?? 0)) { + reject( + new LangSmithSandboxRetryableConnectionError( + message, + retryAfterSeconds(res.headers), + ), + ); + } else { + reject(new LangSmithSandboxConnectionError(message)); + } + }, + ); + + // Stays attached after settling so a late error has a listener and cannot + // become an unhandled 'error' event. + ws.on("error", (err: Error) => { + if (settled) return; + settled = true; + reject( + new LangSmithSandboxConnectTimeoutError( + `Failed to connect to sandbox WebSocket: ${err.message}`, + ), + ); + }); + }); +} + +/** + * Read messages from a ws WebSocket as an async iterable. + * + * Yields parsed WsMessage objects. Handles close events and errors, + * mapping them to appropriate exceptions. + */ +async function* readWsMessages( + ws: WsWebSocket, +): AsyncIterableIterator { + // Buffer incoming messages so the consumer can process them at its own pace + const messageQueue: WsMessage[] = []; + let resolve: (() => void) | null = null; + let error: Error | null = null; + let done = false; + + const onMessage = (data: Buffer | string) => { + const raw = typeof data === "string" ? data : data.toString(); + const msg = JSON.parse(raw) as WsMessage; + messageQueue.push(msg); + if (resolve) { + const r = resolve; + resolve = null; + r(); + } + }; + + const onClose = (code: number, reason: Buffer) => { + done = true; + if (code === 1001) { + error = new LangSmithSandboxServerReloadError( + "Server is reloading, reconnect to resume", + ); + } else if (code !== 1000) { + error = new LangSmithSandboxConnectionError( + `WebSocket connection closed unexpectedly (code: ${code}, reason: ${reason.toString()})`, + ); + } + if (resolve) { + const r = resolve; + resolve = null; + r(); + } + }; + + const onError = (err: Error) => { + done = true; + if (!error) { + error = new LangSmithSandboxConnectionError( + `WebSocket connection error: ${err.message}`, + ); + } + if (resolve) { + const r = resolve; + resolve = null; + r(); + } + }; + + ws.on("message", onMessage); + ws.on("close", onClose); + ws.on("error", onError); + + try { + while (true) { + // Drain buffered messages first + while (messageQueue.length > 0) { + yield messageQueue.shift()!; + } + + // If done and queue is empty, we're finished + if (done) { + if (error) { + throw error; + } + return; + } + + // Wait for next message or close/error + await new Promise((r) => { + resolve = r; + }); + } + } finally { + ws.removeListener("message", onMessage); + ws.removeListener("close", onClose); + ws.removeListener("error", onError); + } +} + +// ============================================================================= +// Async Stream Functions +// ============================================================================= + +/** + * Execute a command over WebSocket, yielding raw message dicts. + * + * Returns a tuple of [async_message_iterator, control]. The control object + * provides sendKill() and sendInput() methods for the CommandHandle. + * + * The iterator yields WsMessage objects with a "type" field: + * - { type: "started", command_id: "...", pid: N } + * - { type: "stdout", data: "...", offset: N } + * - { type: "stderr", data: "...", offset: N } + * - { type: "exit", exit_code: N } + * + * If onStdout/onStderr callbacks are provided, they are invoked as + * data arrives in addition to yielding the messages. + */ +export async function runWsStream( + dataplaneUrl: string, + apiKey: string | undefined, + command: string, + options: WsRunOptions = {}, +): Promise<[AsyncIterableIterator, WSStreamControl]> { + const { + timeout = 60, + env, + cwd, + shell = "/bin/bash", + onStdout, + onStderr, + commandId, + idleTimeout = 300, + killOnDisconnect = false, + ttlSeconds = 600, + pty, + runConfig, + closeStdin, + headers: extraHeaders, + openTimeout = WS_OPEN_TIMEOUT, + } = options; + + const wsUrl = buildWsUrl(dataplaneUrl); + const headers = buildAuthHeaders(apiKey, extraHeaders); + const control = new WSStreamControl(); + + async function* stream(): AsyncIterableIterator { + let ws: WsWebSocket | undefined; + try { + ws = await connectWs(wsUrl, headers, openTimeout); + control._bind(ws); + + // Send execute request + const payload: Record = { + type: "execute", + command, + timeout_seconds: timeout, + shell, + idle_timeout_seconds: idleTimeout, + kill_on_disconnect: killOnDisconnect, + ttl_seconds: ttlSeconds, + }; + if (env) payload.env = env; + if (cwd) payload.cwd = cwd; + if (runConfig) payload.run_config = runConfig; + if (closeStdin) payload.close_stdin = true; + if (commandId) payload.command_id = commandId; + if (pty) payload.pty = true; + + ws.send(JSON.stringify(payload)); + + // Read messages until exit or error + for await (const msg of readWsMessages(ws)) { + const msgType = msg.type; + + if (msgType === "started") { + yield msg; + } else if (msgType === "stdout") { + if (onStdout) onStdout(msg.data as string); + yield msg; + } else if (msgType === "stderr") { + if (onStderr) onStderr(msg.data as string); + yield msg; + } else if (msgType === "exit") { + yield msg; + return; + } else if (msgType === "error") { + raiseForWsError(msg); + } + } + } finally { + control._unbind(); + if (ws && ws.readyState === 1) { + ws.close(); + } + } + } + + return [stream(), control]; +} + +/** + * Reconnect to an existing command over WebSocket. + * + * Returns a tuple of [async_message_iterator, control], same as runWsStream. + * The iterator yields the server's 'started' acknowledgement, then stdout, + * stderr, exit, and error messages. That acknowledgement is the only evidence a + * reattachment landed for a command producing no output, so it is forwarded + * rather than dropped. + */ +export async function reconnectWsStream( + dataplaneUrl: string, + apiKey: string | undefined, + commandId: string, + options: { + stdoutOffset?: number; + stderrOffset?: number; + headers?: Record; + } = {}, +): Promise<[AsyncIterableIterator, WSStreamControl]> { + const { stdoutOffset = 0, stderrOffset = 0, headers: extraHeaders } = options; + + const wsUrl = buildWsUrl(dataplaneUrl); + const headers = buildAuthHeaders(apiKey, extraHeaders); + const control = new WSStreamControl(); + + async function* stream(): AsyncIterableIterator { + let ws: WsWebSocket | undefined; + try { + ws = await connectWs(wsUrl, headers); + control._bind(ws); + + // Send reconnect request + ws.send( + JSON.stringify({ + type: "reconnect", + command_id: commandId, + stdout_offset: stdoutOffset, + stderr_offset: stderrOffset, + }), + ); + + // Read messages until exit or error + for await (const msg of readWsMessages(ws)) { + const msgType = msg.type; + + if ( + msgType === "started" || + msgType === "stdout" || + msgType === "stderr" + ) { + yield msg; + } else if (msgType === "exit") { + yield msg; + return; + } else if (msgType === "error") { + raiseForWsError(msg, commandId); + } + } + } finally { + control._unbind(); + if (ws && ws.readyState === 1) { + ws.close(); + } + } + } + + return [stream(), control]; +} diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts new file mode 100644 index 0000000..241c1bb --- /dev/null +++ b/src/lib/schemas.ts @@ -0,0 +1,814 @@ +export interface TracerSession { + // The ID of the tenant, or organization + tenant_id: string; + // The ID of the project (alias for session) + id: string; + // The start time of the project + start_time: number; + // The end time of the project + end_time?: number; + // A description of the project + description?: string; + // The name of the project + name?: string; + /** Extra metadata for the project. */ + extra?: KVMap; + // The reference dataset ID this session's runs were generated on. + reference_dataset_id?: string; +} + +// Fully loaded information about a Tracer Session (also known +// as a Project) +export interface TracerSessionResult extends TracerSession { + // The number of runs in the session. + run_count?: number; + // The median (50th percentile) latency for the session. + latency_p50?: number; + // The 99th percentile latency for the session. + latency_p99?: number; + // The total number of tokens consumed in the session. + total_tokens?: number; + // The total number of prompt tokens consumed in the session. + prompt_tokens?: number; + // The total number of completion tokens consumed in the session. + completion_tokens?: number; + // The start time of the last run in the session. + last_run_start_time?: number; + // Feedback stats for the session. + feedback_stats?: Record; + // Facets for the runs in the session. + run_facets?: KVMap[]; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type KVMap = Record; +// DEPRECATED: Use a raw string instead. +export type RunType = + | "llm" + | "chain" + | "tool" + | "retriever" + | "embedding" + | "prompt" + | "parser"; +export type ScoreType = number | boolean | null; +export type ValueType = number | boolean | string | object | null; +export type DataType = "kv" | "llm" | "chat"; + +export interface BaseExample { + dataset_id: string; + inputs: KVMap; + outputs?: KVMap; + metadata?: KVMap; + source_run_id?: string; +} + +export interface AttachmentInfo { + presigned_url: string; + mime_type?: string; +} + +export type AttachmentData = Uint8Array | ArrayBuffer; + +export type AttachmentDescription = { + mimeType: string; + data: AttachmentData; +}; +export type Attachments = Record< + string, + [string, AttachmentData] | AttachmentDescription +>; + +/** + * A run can represent either a trace (root run) + * or a child run (~span). + */ +export interface BaseRun { + /** Optionally, a unique identifier for the run. */ + id?: string; + + /** A human-readable name for the run. */ + name: string; + + /** The epoch time at which the run started, if available. */ + start_time?: number | string; + + /** Specifies the type of run (tool, chain, llm, etc.). */ + run_type: string; + + /** The epoch time at which the run ended, if applicable. */ + end_time?: number | string; + + /** Any additional metadata or settings for the run. */ + extra?: KVMap; + + /** Error message, captured if the run faces any issues. */ + error?: string; + + /** Serialized state of the run for potential future use. */ + serialized?: object; + + /** Events like 'start', 'end' linked to the run. */ + events?: KVMap[]; + + /** Inputs that were used to initiate the run. */ + inputs: KVMap; + + /** Outputs produced by the run, if any. */ + outputs?: KVMap; + + /** ID of an example that might be related to this run. */ + reference_example_id?: string; + + /** ID of a parent run, if this run is part of a larger operation. */ + parent_run_id?: string; + + /** Tags for further categorizing or annotating the run. */ + tags?: string[]; + + /** Unique ID assigned to every run within this nested trace. **/ + trace_id?: string; + + /** + * The dotted order for the run. + * + * This is a string composed of {time}{run-uuid}.* so that a trace can be + * sorted in the order it was executed. + * + * Example: + * - Parent: 20230914T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8 + * - Children: + * - 20230914T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8.20230914T223155649Z809ed3a2-0172-4f4d-8a02-a64e9b7a0f8a + * - 20230915T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8.20230914T223155650Zc8d9f4c5-6c5a-4b2d-9b1c-3d9d7a7c5c7c + */ + dotted_order?: string; + + /** + * Attachments associated with the run. + * Each entry is a tuple of [mime_type, bytes] + */ + attachments?: Attachments; +} + +type S3URL = { + ROOT: { + /** A pre-signed URL */ + presigned_url: string; + /** The S3 path to the object in storage */ + s3_url: string; + }; +}; + +/** + * Describes properties of a run when loaded from the database. + * Extends the BaseRun interface. + */ +export interface Run extends BaseRun { + /** A unique identifier for the run, mandatory when loaded from DB. */ + id: string; + + /** The ID of the project that owns this run. */ + session_id?: string; + + /** IDs of any child runs spawned by this run. */ + child_run_ids?: string[]; + + /** Child runs, loaded explicitly via a heavier query. */ + child_runs?: Run[]; + + /** Stats capturing feedback for this run. */ + feedback_stats?: KVMap; + + /** The URL path where this run is accessible within the app. */ + app_path?: string; + + /** The manifest ID that correlates with this run. */ + manifest_id?: string; + + /** The current status of the run, such as 'success'. */ + status?: string; + + /** Number of tokens used in the prompt. */ + prompt_tokens?: number; + + /** Number of tokens generated in the completion. */ + completion_tokens?: number; + + /** Total token count, combining prompt and completion. */ + total_tokens?: number; + + /** Time when the first token was processed. */ + first_token_time?: number; + + /** IDs of parent runs, if multiple exist. */ + parent_run_ids?: string[]; + + /** Whether the run is included in a dataset. */ + in_dataset?: boolean; + + /** The output S3 URLs */ + outputs_s3_urls?: S3URL; + + /** The input S3 URLs */ + inputs_s3_urls?: S3URL; +} + +export interface RunCreate extends BaseRun { + revision_id?: string; + child_runs?: this[]; + session_name?: string; +} + +export interface RunUpdate { + id?: string; + name?: string; + run_type?: string; + start_time?: number | string; + end_time?: number | string; + extra?: KVMap; + tags?: string[]; + error?: string; + serialized?: object; + inputs?: KVMap; + outputs?: KVMap; + parent_run_id?: string; + reference_example_id?: string; + events?: KVMap[]; + session_id?: string; + session_name?: string; + /** Unique ID assigned to every run within this nested trace. **/ + trace_id?: string; + + /** + * The dotted order for the run. + * + * This is a string composed of {time}{run-uuid}.* so that a trace can be + * sorted in the order it was executed. + * + * Example: + * - Parent: 20230914T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8 + * - Children: + * - 20230914T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8.20230914T223155649Z809ed3a2-0172-4f4d-8a02-a64e9b7a0f8a + * - 20230915T223155647Z1b64098b-4ab7-43f6-afee-992304f198d8.20230914T223155650Zc8d9f4c5-6c5a-4b2d-9b1c-3d9d7a7c5c7c + */ + dotted_order?: string; + + /** + * Attachments associated with the run. + * Each entry is a tuple of [mime_type, bytes] + */ + attachments?: Attachments; +} + +export interface ExampleCreate { + id?: string; + inputs: KVMap; + outputs?: KVMap; + metadata?: KVMap; + split?: string | string[]; + attachments?: Attachments; + created_at?: string; + dataset_id?: string; + dataset_name?: string; + source_run_id?: string; + use_source_run_io?: boolean; + use_source_run_attachments?: string[]; +} + +export interface ExampleUploadWithAttachments extends ExampleCreate {} +export interface ExampleUpdate { + id: string; + inputs?: KVMap; + outputs?: KVMap; + metadata?: KVMap; + split?: string | string[]; + attachments?: Attachments; + attachments_operations?: KVMap; + dataset_id?: string; +} + +export interface ExampleUpdateWithoutId extends Omit {} + +export interface ExampleUpdateWithAttachments extends ExampleUpdate {} + +export interface UploadExamplesResponse { + count: number; + example_ids: string[]; + as_of?: string; +} + +export interface UpdateExamplesResponse extends UploadExamplesResponse {} + +export interface Example extends BaseExample { + id: string; + created_at: string; + modified_at?: string; + source_run_id?: string; + runs: Run[]; + attachments?: Record; + split?: string | string[]; +} + +interface RawAttachmentInfo { + presigned_url: string; + s3_url: string; + mime_type?: string; +} +export interface RawExample extends BaseExample { + id: string; + created_at: string; + modified_at: string; + source_run_id?: string; + runs: Run[]; + attachment_urls?: Record; +} + +export interface ExampleUpdateWithId extends ExampleUpdate {} + +export interface BaseDataset { + name: string; + description: string; + tenant_id: string; + data_type?: DataType; + inputs_schema_definition?: KVMap; + outputs_schema_definition?: KVMap; +} + +export interface Dataset extends BaseDataset { + id: string; + created_at: string; + modified_at: string; + example_count?: number; + session_count?: number; + last_session_start_time?: number; +} +export interface DatasetShareSchema { + dataset_id: string; + share_token: string; + url: string; +} + +export interface DatasetVersion { + tags?: string[]; + as_of: string; +} + +export interface FeedbackSourceBase { + type: string; + metadata?: KVMap; +} + +export interface APIFeedbackSource extends FeedbackSourceBase { + type: "api"; +} + +export interface ModelFeedbackSource extends FeedbackSourceBase { + type: "model"; +} + +export interface FeedbackBase { + created_at: string; + modified_at: string; + run_id: string; + /** The trace the run belongs to. Equals run_id for a root run. */ + trace_id?: string; + key: string; + score: ScoreType; + value: ValueType; + comment: string | null; + correction: string | object | null; + feedback_source: APIFeedbackSource | ModelFeedbackSource | KVMap | null; +} + +export interface FeedbackCreate extends FeedbackBase { + id: string; +} + +export interface Feedback extends FeedbackBase { + id: string; +} + +export interface LangChainBaseMessage { + _getType: () => string; + content: string; + additional_kwargs?: KVMap; +} + +export interface FeedbackIngestToken { + id: string; + url: string; + expires_at: string; +} + +export interface TimeDelta { + days?: number; + hours?: number; + minutes?: number; +} + +export interface FeedbackCategory { + value: number; + label?: string | null; +} + +/** + * Represents the configuration for feedback. + * This determines how the LangSmith service interprets feedback + * values of the associated key. + */ +export interface FeedbackConfig { + /** + * The type of feedback. + * - "continuous": Feedback with a continuous numeric. + * - "categorical": Feedback with a categorical value (classes) + * - "freeform": Feedback with a freeform text value (notes). + */ + type: "continuous" | "categorical" | "freeform"; + + /** + * The minimum value for continuous feedback. + */ + min?: number | null; + + /** + * The maximum value for continuous feedback. + */ + max?: number | null; + + /** + * The categories for categorical feedback. + * Each category can be a string or an object with additional properties. + * + * If feedback is categorical, this defines the valid categories the server will accept. + * Not applicable to continuous or freeform feedback types. + */ + categories?: FeedbackCategory[] | null; +} + +export interface DatasetDiffInfo { + examples_modified: string[]; + examples_added: string[]; + examples_removed: string[]; +} + +export interface ComparisonEvaluationResult { + key: string; + scores: Record; + source_run_id?: string; +} + +export interface ComparativeExperiment { + id: string; + name: string; + description: string; + tenant_id: string; + created_at: string; + modified_at: string; + reference_dataset_id: string; + extra?: Record; + experiments_info?: Array>; + feedback_stats?: Record; +} + +/** + * Represents the expected output schema returned by traceable + * or by run tree output for LangSmith to correctly display + * documents in the UI + */ +export type RetrieverOutput = Array<{ + page_content: string; + type: "Document"; + metadata?: KVMap; +}>; + +export interface InvocationParamsSchema { + ls_provider?: string; + ls_model_name?: string; + ls_model_type: "chat" | "llm"; + ls_temperature?: number; + ls_max_tokens?: number; + ls_stop?: string[]; + ls_invocation_params?: Record; +} + +export interface PromptCommit { + owner: string; + repo: string; + commit_hash: string; + manifest: Record; + examples: Array>; + description?: string; + /** The model configuration for the prompt. */ + hub_model_config?: Record; + /** The model provider (e.g. ChatOpenAI) */ + hub_model_provider?: string; +} + +export interface Prompt { + repo_handle: string; + description?: string; + readme?: string; + id: string; + tenant_id: string; + created_at: string; + updated_at: string; + is_public: boolean; + is_archived: boolean; + tags: string[]; + original_repo_id?: string; + upstream_repo_id?: string; + owner?: string; + full_name: string; + num_likes: number; + num_downloads: number; + num_views: number; + liked_by_auth_user: boolean; + last_commit_hash?: string; + num_commits: number; + original_repo_full_name?: string; + upstream_repo_full_name?: string; +} + +export interface ListPromptsResponse { + repos: Prompt[]; + total: number; +} + +export interface ListCommitsResponse { + commits: PromptCommit[]; + total: number; +} + +export type PromptSortField = + | "num_downloads" + | "num_views" + | "updated_at" + | "num_likes"; + +export interface LikePromptResponse { + likes: number; +} + +export interface FileEntry { + type: "file"; + content: string; +} + +export interface AgentEntry { + type: "agent"; + repo_handle: string; + commit_id?: string; + owner?: string; + commit_hash?: string; +} + +export interface SkillEntry { + type: "skill"; + repo_handle: string; + commit_id?: string; + owner?: string; + commit_hash?: string; +} + +export type Entry = FileEntry | AgentEntry | SkillEntry; + +/** The type of a non-prompt hub repo. */ +export type HubRepoType = "agent" | "skill"; + +/** + * An agent pulled from hub. + */ +export interface AgentContext { + /** The commit ID. */ + commit_id: string; + /** The commit hash. */ + commit_hash: string; + /** The files in the agent. */ + files: Record; +} + +/** + * A skill pulled from hub. + */ +export interface SkillContext { + /** The commit ID. */ + commit_id: string; + /** The commit hash. */ + commit_hash: string; + /** The files in the skill. */ + files: Record; +} + +/** Response body for `POST /directories/commits`. */ +export interface DirectoryCommitResponse { + commit: { + /** The commit ID. */ + id: string; + /** The commit hash. */ + commit_hash: string; + /** When the commit was created. */ + created_at: string; + }; +} + +export interface LangSmithSettings { + id: string; + display_name: string; + created_at: string; + tenant_handle?: string; +} + +export interface AnnotationQueue { + /** The unique identifier of the annotation queue. */ + id: string; + + /** The name of the annotation queue. */ + name: string; + + /** An optional description of the annotation queue. */ + description?: string; + + /** The timestamp when the annotation queue was created. */ + created_at: string; + + /** The timestamp when the annotation queue was last updated. */ + updated_at: string; + + /** The ID of the tenant associated with the annotation queue. */ + tenant_id: string; +} + +export interface AnnotationQueueRubricItem { + /** The feedback key this rubric item is associated with. */ + feedback_key: string; + + /** An optional description of the rubric item. */ + description?: string | null; + + /** Descriptions for categorical values. */ + value_descriptions?: Record | null; + + /** Descriptions for continuous score values. */ + score_descriptions?: Record | null; + + /** Whether this rubric item is required. */ + is_required?: boolean | null; +} + +export interface AnnotationQueueWithDetails extends AnnotationQueue { + /** The rubric instructions for the annotation queue. */ + rubric_instructions?: string; + + /** The rubric items for the annotation queue. */ + rubric_items?: AnnotationQueueRubricItem[]; +} + +/** + * A run identified by its full lookup key, for adding to an annotation queue. + * Unlike a bare run ID, this carries the partition key (`sessionId` and + * `startTime`) so the run can be located directly, without a scan. + */ +export interface RunKey { + /** The ID of the run to add to the queue. */ + runId: string; + /** The ID of the project/session the run belongs to (partition key). */ + sessionId: string; + /** The start time of the run (partition key): a Date, epoch ms, or ISO string. */ + startTime: string | number | Date; + /** Optional back-pointer to the issues-agent proposed example that seeded this queue item. */ + sourceProposedExampleId?: string; +} + +export interface FeedbackConfigSchema { + /** The unique key identifying this feedback configuration. */ + feedback_key: string; + + /** The configuration specifying the type, bounds, and categories. */ + feedback_config: FeedbackConfig; + + /** The ID of the tenant that owns this feedback configuration. */ + tenant_id: string; + + /** When this feedback configuration was last modified. */ + modified_at: string; + + /** Whether a lower score is considered better for this feedback key. */ + is_lower_score_better?: boolean | null; +} + +export interface RunWithAnnotationQueueInfo extends Exclude { + /** An unique identifier for the run. */ + id: string; + + /** The last time this run was reviewed. */ + last_reviewed_time?: string; + + /** The time this run was added to the queue. */ + added_at?: string; +} + +/** + * Breakdown of input token counts. + * + * Does not *need* to sum to full input token count. Does *not* need to have all keys. + */ +export type InputTokenDetails = { + /** + * Audio input tokens. + */ + audio?: number; + + /** + * Input tokens that were cached and there was a cache hit. + * + * Since there was a cache hit, the tokens were read from the cache. + * More precisely, the model state given these tokens was read from the cache. + */ + cache_read?: number; + + /** + * Input tokens that were cached and there was a cache miss. + * + * Since there was a cache miss, the cache was created from these tokens. + */ + cache_creation?: number; +} & Record; + +/** + * Breakdown of output token counts. + * + * Does *not* need to sum to full output token count. Does *not* need to have all keys. + */ +export type OutputTokenDetails = { + /** + * Audio output tokens + */ + audio?: number; + + /** + * Reasoning output tokens. + * + * Tokens generated by the model in a chain of thought process (i.e. by + * OpenAI's o1 models) that are not returned as part of model output. + */ + reasoning?: number; +} & Record; + +/** + * Usage metadata for a message, such as token counts. + */ +export type UsageMetadata = { + /** + * Count of input (or prompt) tokens. Sum of all input token types. + */ + input_tokens: number; + /** + * Count of output (or completion) tokens. Sum of all output token types. + */ + output_tokens: number; + /** + * Total token count. Sum of input_tokens + output_tokens. + */ + total_tokens: number; + + /** + * Breakdown of input token counts. + * + * Does *not* need to sum to full input token count. Does *not* need to have all keys. + */ + input_token_details?: InputTokenDetails; + + /** + * Breakdown of output token counts. + * + * Does *not* need to sum to full output token count. Does *not* need to have all keys. + */ + output_token_details?: OutputTokenDetails; + + /** + * The cost of the input tokens. + */ + input_cost?: number; + + /** + * The cost of the output tokens. + */ + output_cost?: number; + + /** + * The total cost of the tokens. + */ + total_cost?: number; + + /** + * The cost details of the input tokens. + */ + input_cost_details?: Record; + + /** + * The cost details of the output tokens. + */ + output_cost_details?: Record; +}; + +export type ExtractedUsageMetadata = Partial; diff --git a/src/lib/singletons/constants.ts b/src/lib/singletons/constants.ts new file mode 100644 index 0000000..27a760b --- /dev/null +++ b/src/lib/singletons/constants.ts @@ -0,0 +1,18 @@ +export const _LC_CONTEXT_VARIABLES_KEY = Symbol.for("lc:context_variables"); + +export const _LC_CHILD_RUN_END_PROMISES_KEY = Symbol.for( + "lc:child_run_end_promises", +); + +export const _REPLICA_TRACE_ROOTS_KEY = Symbol.for( + "langsmith:replica_trace_roots", +); + +// Marker for a payload whose redactor threw. +// Takes the hook name, never the error: reading e.constructor runs user code. +export const _PROCESSING_FAILED_KEY = "ls_error"; +export const _PROCESSING_FAILED_PREFIX = "Processing failed; "; +export const _processingFailed = ( + payload: "inputs" | "outputs", + hook: string, +): string => `${_PROCESSING_FAILED_PREFIX}${payload} dropped (${hook})`; diff --git a/src/lib/singletons/fetch.ts b/src/lib/singletons/fetch.ts new file mode 100644 index 0000000..aab0288 --- /dev/null +++ b/src/lib/singletons/fetch.ts @@ -0,0 +1,66 @@ +import { getLangSmithEnvironmentVariable } from "../utils/env.js"; + +// Wrap the default fetch call due to issues with illegal invocations +// in some environments: +// https://stackoverflow.com/questions/69876859/why-does-bind-fix-failed-to-execute-fetch-on-window-illegal-invocation-err +// @ts-expect-error Broad typing to support a range of fetch implementations +const DEFAULT_FETCH_IMPLEMENTATION = (...args: any[]) => fetch(...args); + +let globalFetchSupportsWebStreaming: boolean | undefined = undefined; + +const LANGSMITH_FETCH_IMPLEMENTATION_KEY = Symbol.for( + "ls:fetch_implementation", +); + +/** + * Overrides the fetch implementation used for LangSmith calls. + * You should use this if you need to use an implementation of fetch + * other than the default global (e.g. for dealing with proxies). + * @param fetch The new fetch function to use. + */ +export const overrideFetchImplementation = ( + fetch: (...args: any[]) => any, + supportsWebStreaming?: boolean, +) => { + (globalThis as any)[LANGSMITH_FETCH_IMPLEMENTATION_KEY] = fetch; + globalFetchSupportsWebStreaming = supportsWebStreaming; +}; + +export const clearFetchImplementation = () => { + delete (globalThis as any)[LANGSMITH_FETCH_IMPLEMENTATION_KEY]; + globalFetchSupportsWebStreaming = undefined; +}; + +export const _shouldStreamForGlobalFetchImplementation = () => { + const overriddenFetchImpl = (globalThis as any)[ + LANGSMITH_FETCH_IMPLEMENTATION_KEY + ]; + + if (overriddenFetchImpl === undefined) { + return true; + } + + return globalFetchSupportsWebStreaming ?? false; +}; + +/** + * @internal + */ +export const _getFetchImplementation: ( + debug?: boolean, +) => (...args: any[]) => any = (debug?: boolean) => { + return async (...args: any[]) => { + if (debug || getLangSmithEnvironmentVariable("DEBUG") === "true") { + const [url, options] = args; + console.log(`→ ${options?.method || "GET"} ${url}`); + } + const res = await ( + (globalThis as any)[LANGSMITH_FETCH_IMPLEMENTATION_KEY] ?? + DEFAULT_FETCH_IMPLEMENTATION + )(...args); + if (debug || getLangSmithEnvironmentVariable("DEBUG") === "true") { + console.log(`← ${res.status} ${res.statusText} ${res.url}`); + } + return res; + }; +}; diff --git a/src/lib/singletons/otel.ts b/src/lib/singletons/otel.ts new file mode 100644 index 0000000..56d3abe --- /dev/null +++ b/src/lib/singletons/otel.ts @@ -0,0 +1,201 @@ +// Should not import any OTEL packages to avoid pulling in optional deps. + +import { resolveTracingMode } from "../utils/env.js"; + +interface OTELTraceInterface { + getTracer: (name: string, version?: string) => any; + getActiveSpan: () => any; + setSpan: (context: any, span: any) => any; + getSpan: (context: any) => any; + setSpanContext: (context: any, spanContext: any) => any; + getTracerProvider: () => any; + setGlobalTracerProvider: (tracerProvider: any) => boolean; +} + +interface OTELContextInterface { + active: () => any; + with: (context: any, fn: () => T) => T; +} + +interface OTELInterface { + trace: OTELTraceInterface; + context: OTELContextInterface; +} + +class MockTracer { + private hasWarned = false; + + startActiveSpan(_name: string, ...args: any[]): T | undefined { + if (!this.hasWarned && resolveTracingMode() === "otel") { + console.warn( + "OTel tracing mode is active (via LANGSMITH_TRACING_MODE, OTEL_ENABLED, or " + + "LANGSMITH_OTEL_ENABLED), but the required OTEL instances have not been initialized. " + + 'Please add:\n```\nimport { initializeOTEL } from "langsmith/experimental/otel/setup";\ninitializeOTEL();\n```\nat the beginning of your code.', + ); + this.hasWarned = true; + } + + // Handle different overloads: + // startActiveSpan(name, fn) + // startActiveSpan(name, options, fn) + // startActiveSpan(name, options, context, fn) + let fn: ((...args: any[]) => T) | undefined; + + if (args.length === 1 && typeof args[0] === "function") { + fn = args[0]; + } else if (args.length === 2 && typeof args[1] === "function") { + fn = args[1]; + } else if (args.length === 3 && typeof args[2] === "function") { + fn = args[2]; + } + + if (typeof fn === "function") { + return fn(); + } + return undefined; + } +} + +class MockOTELTrace implements OTELTraceInterface { + private mockTracer = new MockTracer(); + + getTracer(_name: string, _version?: string) { + return this.mockTracer; + } + + getActiveSpan() { + return undefined; + } + + setSpan(context: any, _span: any) { + return context; + } + + getSpan(_context: any) { + return undefined; + } + + setSpanContext(context: any, _spanContext: any) { + return context; + } + + getTracerProvider() { + return undefined; + } + + setGlobalTracerProvider(_tracerProvider: any) { + return false; + } +} + +class MockOTELContext implements OTELContextInterface { + active() { + return {}; + } + + with(_context: any, fn: () => T): T { + return fn(); + } +} + +const OTEL_TRACE_KEY = Symbol.for("ls:otel_trace"); +const OTEL_CONTEXT_KEY = Symbol.for("ls:otel_context"); +const OTEL_GET_DEFAULT_OTLP_TRACER_PROVIDER_KEY = Symbol.for( + "ls:otel_get_default_otlp_tracer_provider", +); + +const mockOTELTrace = new MockOTELTrace(); +const mockOTELContext = new MockOTELContext(); + +class OTELProvider { + getTraceInstance(): OTELTraceInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (globalThis as any)[OTEL_TRACE_KEY] ?? mockOTELTrace; + } + + getContextInstance(): OTELContextInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (globalThis as any)[OTEL_CONTEXT_KEY] ?? mockOTELContext; + } + + initializeGlobalInstances(otel: OTELInterface) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((globalThis as any)[OTEL_TRACE_KEY] === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any)[OTEL_TRACE_KEY] = otel.trace; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((globalThis as any)[OTEL_CONTEXT_KEY] === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any)[OTEL_CONTEXT_KEY] = otel.context; + } + } + + setDefaultOTLPTracerComponents(components: { + DEFAULT_LANGSMITH_SPAN_PROCESSOR: any; + DEFAULT_LANGSMITH_TRACER_PROVIDER: any; + DEFAULT_LANGSMITH_SPAN_EXPORTER: any; + }) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any)[OTEL_GET_DEFAULT_OTLP_TRACER_PROVIDER_KEY] = components; + } + + getDefaultOTLPTracerComponents() { + return ( + (globalThis as any)[OTEL_GET_DEFAULT_OTLP_TRACER_PROVIDER_KEY] ?? + undefined + ); + } +} + +export const OTELProviderSingleton = new OTELProvider(); + +/** + * Get the current OTEL trace instance. + * Returns a mock implementation if OTEL is not available. + */ +export function getOTELTrace(): OTELTraceInterface { + return OTELProviderSingleton.getTraceInstance(); +} + +/** + * Get the current OTEL context instance. + * Returns a mock implementation if OTEL is not available. + */ +export function getOTELContext(): OTELContextInterface { + return OTELProviderSingleton.getContextInstance(); +} + +/** + * Initialize the global OTEL instances. + * Should be called once when OTEL packages are available. + */ +export function setOTELInstances(otel: OTELInterface): void { + OTELProviderSingleton.initializeGlobalInstances(otel); +} + +/** + * Set a getter function for the default OTLP tracer provider. + * This allows lazy initialization of the tracer provider. + */ +export function setDefaultOTLPTracerComponents(components: { + DEFAULT_LANGSMITH_SPAN_PROCESSOR: any; + DEFAULT_LANGSMITH_TRACER_PROVIDER: any; + DEFAULT_LANGSMITH_SPAN_EXPORTER: any; +}): void { + OTELProviderSingleton.setDefaultOTLPTracerComponents(components); +} + +/** + * Get the default OTLP tracer provider instance. + * Returns undefined if not set. + */ +export function getDefaultOTLPTracerComponents(): + | { + DEFAULT_LANGSMITH_SPAN_PROCESSOR: any; + DEFAULT_LANGSMITH_TRACER_PROVIDER: any; + DEFAULT_LANGSMITH_SPAN_EXPORTER: any; + } + | undefined { + return OTELProviderSingleton.getDefaultOTLPTracerComponents(); +} diff --git a/src/lib/singletons/traceable.ts b/src/lib/singletons/traceable.ts new file mode 100644 index 0000000..2e877cb --- /dev/null +++ b/src/lib/singletons/traceable.ts @@ -0,0 +1,93 @@ +import type { RunTree } from "../run_trees.js"; +import type { ContextPlaceholder, TraceableFunction } from "./types.js"; + +interface AsyncLocalStorageInterface { + getStore: () => RunTree | ContextPlaceholder | undefined; + + run: ( + context: RunTree | ContextPlaceholder | undefined, + fn: () => void, + ) => void; +} + +class MockAsyncLocalStorage implements AsyncLocalStorageInterface { + getStore() { + return undefined; + } + + run(_: RunTree | ContextPlaceholder | undefined, callback: () => void): void { + return callback(); + } +} + +const TRACING_ALS_KEY = Symbol.for("ls:tracing_async_local_storage"); + +const mockAsyncLocalStorage = new MockAsyncLocalStorage(); + +class AsyncLocalStorageProvider { + getInstance(): AsyncLocalStorageInterface { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (globalThis as any)[TRACING_ALS_KEY] ?? mockAsyncLocalStorage; + } + + initializeGlobalInstance(instance: AsyncLocalStorageInterface) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((globalThis as any)[TRACING_ALS_KEY] === undefined) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any)[TRACING_ALS_KEY] = instance; + } + } +} + +export const AsyncLocalStorageProviderSingleton = + new AsyncLocalStorageProvider(); + +/** + * Return the current run tree from within a traceable-wrapped function. + * Will throw an error if called outside of a traceable function. + * + * @returns The run tree for the given context. + */ +export function getCurrentRunTree(): RunTree; + +export function getCurrentRunTree(permitAbsentRunTree: false): RunTree; + +export function getCurrentRunTree( + permitAbsentRunTree: boolean, +): RunTree | undefined; + +export function getCurrentRunTree(permitAbsentRunTree = false) { + const runTree = AsyncLocalStorageProviderSingleton.getInstance().getStore(); + if (!permitAbsentRunTree && runTree === undefined) { + throw new Error( + "Could not get the current run tree.\n\nPlease make sure you are calling this method within a traceable function and that tracing is enabled.", + ); + } + + return runTree; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function withRunTree any>( + runTree: RunTree, + fn: Fn, +): Promise>> { + const storage = AsyncLocalStorageProviderSingleton.getInstance(); + return new Promise>>((resolve, reject) => { + storage.run( + runTree, + () => void Promise.resolve(fn()).then(resolve).catch(reject), + ); + }); +} + +export const ROOT = Symbol.for("langsmith:traceable:root"); + +export function isTraceableFunction( + x: unknown, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): x is TraceableFunction { + return typeof x === "function" && "langsmith:traceable" in x; +} + +export type { TraceableFunction } from "./types.js"; diff --git a/src/lib/singletons/types.ts b/src/lib/singletons/types.ts new file mode 100644 index 0000000..524a2af --- /dev/null +++ b/src/lib/singletons/types.ts @@ -0,0 +1,86 @@ +import { RunTree, RunnableConfigLike } from "../run_trees.js"; +import { ROOT } from "./traceable.js"; +import { _LC_CONTEXT_VARIABLES_KEY } from "./constants.js"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type SmartPromise = T extends AsyncIterable + ? T + : T extends Promise + ? T + : Promise; +type WrapArgReturnPair = Pair extends [ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + infer Args extends any[], + infer Return, +] + ? Args extends [RunTree, ...infer RestArgs] + ? { + ( + runTree: RunTree | typeof ROOT, + ...args: RestArgs + ): SmartPromise; + /** @deprecated Will be removed in 0.4 */ + (config: RunnableConfigLike, ...args: RestArgs): SmartPromise; + } + : { + (...args: Args): SmartPromise; + (runTree: RunTree | typeof ROOT, ...rest: Args): SmartPromise; + /** @deprecated Will be removed in 0.4 */ + (config: RunnableConfigLike, ...args: Args): SmartPromise; + } + : never; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type UnionToIntersection = (U extends any ? (x: U) => void : never) extends ( + x: infer I, +) => void + ? I + : never; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TraceableFunction any> = + // function overloads are represented as intersections rather than unions + // matches the behavior introduced in https://github.com/microsoft/TypeScript/pull/54448 + (Func extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + (...args: infer A5): infer R5; + } + ? UnionToIntersection< + WrapArgReturnPair<[A1, R1] | [A2, R2] | [A3, R3] | [A4, R4] | [A5, R5]> + > + : Func extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + (...args: infer A4): infer R4; + } + ? UnionToIntersection< + WrapArgReturnPair<[A1, R1] | [A2, R2] | [A3, R3] | [A4, R4]> + > + : Func extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + (...args: infer A3): infer R3; + } + ? UnionToIntersection> + : Func extends { + (...args: infer A1): infer R1; + (...args: infer A2): infer R2; + } + ? UnionToIntersection> + : Func extends { + (...args: infer A1): infer R1; + } + ? UnionToIntersection> + : never) & { + // Other properties of Func + [K in keyof Func]: Func[K]; + }; + +export type RunTreeLike = RunTree; + +export type ContextPlaceholder = { + [_LC_CONTEXT_VARIABLES_KEY]?: Record; + tracingEnabled?: boolean; +}; diff --git a/src/lib/tests/anonymizer.test.ts b/src/lib/tests/anonymizer.test.ts new file mode 100644 index 0000000..d3fb2e4 --- /dev/null +++ b/src/lib/tests/anonymizer.test.ts @@ -0,0 +1,596 @@ +import { + StringNodeRule, + createAnonymizer, + createSecretAnonymizer, + DEFAULT_SECRET_RULES, + SECRET_PLACEHOLDER, +} from "../anonymizer/index.js"; +import { v4 as uuid } from "../utils/uuid/src/index.js"; +import { traceable } from "../traceable.js"; +import { BaseMessage, SystemMessage } from "@langchain/core/messages"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; + +const EMAIL_REGEX = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}/g; +const UUID_REGEX = + /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g; + +describe("prototype pollution prevention", () => { + test("blocks constructor.prototype path", () => { + const anonymizer = createAnonymizer([ + { pattern: "secret", replace: "[REDACTED]" }, + ]); + + // Verify clean state + expect(({} as Record).isAdmin).toBeUndefined(); + + // Malicious input with prototype pollution attempt + const maliciousInput = { + wrapper: { + "constructor.prototype.isAdmin": "this-is-secret-data", + }, + }; + + anonymizer(maliciousInput); + + // Should NOT pollute Object.prototype + expect(({} as Record).isAdmin).toBeUndefined(); + }); + + test("blocks __proto__ path", () => { + const anonymizer = createAnonymizer([ + { pattern: "secret", replace: "[REDACTED]" }, + ]); + + expect(({} as Record).polluted).toBeUndefined(); + + const maliciousInput = { + "__proto__.polluted": "secret-data", + }; + + anonymizer(maliciousInput); + + expect(({} as Record).polluted).toBeUndefined(); + }); + + test("blocks prototype path", () => { + const anonymizer = createAnonymizer([ + { pattern: "secret", replace: "[REDACTED]" }, + ]); + + expect(({} as Record).polluted).toBeUndefined(); + + const maliciousInput = { + prototype: { + polluted: "secret-data", + }, + }; + + anonymizer(maliciousInput); + + expect(({} as Record).polluted).toBeUndefined(); + }); + + test("handles dotted keys distinctly from nested keys", () => { + const anonymizer = createAnonymizer([ + { pattern: "secret", replace: "[REDACTED]" }, + ]); + + const input = { + "a.b": "secret-1", + a: { b: "secret-2" }, + }; + + const output = anonymizer(input); + + expect(output["a.b"]).toBe("[REDACTED]-1"); + expect(output.a.b).toBe("[REDACTED]-2"); + }); +}); + +describe("replacer", () => { + const replacer = (text: string) => + text.replace(EMAIL_REGEX, "[email address]").replace(UUID_REGEX, "[uuid]"); + + test("object", () => { + expect( + createAnonymizer(replacer)({ + message: "Hello, this is my email: hello@example.com", + metadata: uuid(), + }), + ).toEqual({ + message: "Hello, this is my email: [email address]", + metadata: "[uuid]", + }); + }); + + test("array", () => { + expect(createAnonymizer(replacer)(["human", "hello@example.com"])).toEqual([ + "human", + "[email address]", + ]); + }); + + test("string", () => { + expect(createAnonymizer(replacer)("hello@example.com")).toEqual( + "[email address]", + ); + }); +}); + +describe("declared", () => { + const replacers: StringNodeRule[] = [ + { pattern: EMAIL_REGEX, replace: "[email address]" }, + { pattern: UUID_REGEX, replace: "[uuid]" }, + ]; + + test("object", () => { + expect( + createAnonymizer(replacers)({ + message: "Hello, this is my email: hello@example.com", + metadata: uuid(), + }), + ).toEqual({ + message: "Hello, this is my email: [email address]", + metadata: "[uuid]", + }); + }); + + test("array", () => { + expect(createAnonymizer(replacers)(["human", "hello@example.com"])).toEqual( + ["human", "[email address]"], + ); + }); + + test("string", () => { + expect(createAnonymizer(replacers)("hello@example.com")).toEqual( + "[email address]", + ); + }); +}); + +describe("client", () => { + test("messages", async () => { + const anonymizer = createAnonymizer([ + { pattern: EMAIL_REGEX, replace: "[email]" }, + { pattern: UUID_REGEX, replace: "[uuid]" }, + ]); + + const { client, callSpy } = mockClient({ anonymizer }); + + const id = uuid(); + const child = traceable( + (value: { messages: BaseMessage[]; values: Record }) => { + return [ + ...value.messages.map((message) => message.content.toString()), + ...Object.entries(value.values).map((lst) => lst.join(": ")), + ].join("\n"); + }, + { name: "child" }, + ); + + const evaluate = traceable( + (values: Record) => { + const messages = [new SystemMessage(`UUID: ${id}`)]; + return child({ messages, values }); + }, + { client, name: "evaluate", tracingEnabled: true }, + ); + + const result = await evaluate({ email: "hello@example.com" }); + + expect(result).toEqual( + [`UUID: ${id}`, `email: hello@example.com`].join("\n"), + ); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["evaluate:0", "child:1"], + data: { + "evaluate:0": { + inputs: { email: "[email]" }, + outputs: { outputs: [`UUID: [uuid]`, `email: [email]`].join("\n") }, + }, + "child:1": { + inputs: { + messages: [ + { + lc: 1, + type: "constructor", + id: ["langchain_core", "messages", "SystemMessage"], + kwargs: { content: "UUID: [uuid]" }, + }, + ], + values: { email: "[email]" }, + }, + outputs: { outputs: [`UUID: [uuid]`, `email: [email]`].join("\n") }, + }, + }, + }); + }); +}); + +describe("error field", () => { + // Assembled at runtime so no literal secret-shaped string sits in source. + const fakeKey = "sk-ant-api03-" + "A".repeat(30); + const errorWithSecret = `ClientResponseError: 401, headers={"Authorization": "Bearer ${fakeKey}"}`; + + test("anonymizer redacts the error field on run create", async () => { + const { client, callSpy } = mockClient({ + anonymizer: createSecretAnonymizer(), + }); + + await client.createRun({ + id: uuid(), + name: "errored", + run_type: "llm", + inputs: { in: "put" }, + error: errorWithSecret, + }); + + const { data } = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(data["errored:0"].error).not.toContain(fakeKey); + expect(data["errored:0"].error).toContain(SECRET_PLACEHOLDER); + }); + + test("anonymizer redacts the error field on run update", async () => { + const { client, callSpy } = mockClient({ + anonymizer: createSecretAnonymizer(), + }); + + const id = uuid(); + await client.createRun({ + id, + name: "errored", + run_type: "llm", + inputs: { in: "put" }, + }); + await client.updateRun(id, { error: errorWithSecret }); + + const { data } = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(data["errored:0"].error).not.toContain(fakeKey); + expect(data["errored:0"].error).toContain(SECRET_PLACEHOLDER); + }); + + test("error is left unchanged when no anonymizer is configured", async () => { + const { client, callSpy } = mockClient({}); + + await client.createRun({ + id: uuid(), + name: "errored", + run_type: "llm", + inputs: { in: "put" }, + error: errorWithSecret, + }); + + const { data } = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(data["errored:0"].error).toBe(errorWithSecret); + }); +}); + +describe("metadata field", () => { + // Assembled at runtime so no literal secret-shaped string sits in source. + const fakeKey = "sk-ant-api03-" + "A".repeat(30); + const metadataWithSecret = () => ({ + config: { authorization: `Bearer ${fakeKey}` }, + }); + + test("anonymizer redacts the metadata field on run create", async () => { + const { client, callSpy } = mockClient({ + anonymizer: createSecretAnonymizer(), + }); + + await client.createRun({ + id: uuid(), + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + extra: { metadata: metadataWithSecret() }, + }); + + const { data } = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const metadata = JSON.stringify(data["traced:0"].extra?.metadata); + expect(metadata).not.toContain(fakeKey); + expect(metadata).toContain(SECRET_PLACEHOLDER); + }); + + test("anonymizer redacts the metadata field on run update", async () => { + const { client, callSpy } = mockClient({ + anonymizer: createSecretAnonymizer(), + }); + + const id = uuid(); + await client.createRun({ + id, + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + }); + await client.updateRun(id, { extra: { metadata: metadataWithSecret() } }); + + const { data } = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const metadata = JSON.stringify(data["traced:0"].extra?.metadata); + expect(metadata).not.toContain(fakeKey); + expect(metadata).toContain(SECRET_PLACEHOLDER); + }); + + test("anonymizer redacts metadata merged in from the environment", async () => { + // Read once at construction, so it has to be set before the client exists. + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_TEST_ENV_METADATA = `Bearer ${fakeKey}`; + try { + const { client, callSpy } = mockClient({ + anonymizer: createSecretAnonymizer(), + }); + + await client.createRun({ + id: uuid(), + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + }); + + const { data } = await getAssumedTreeFromCalls( + callSpy.mock.calls, + client, + ); + const metadata = JSON.stringify(data["traced:0"].extra?.metadata); + expect(metadata).not.toContain(fakeKey); + expect(metadata).toContain(SECRET_PLACEHOLDER); + } finally { + // eslint-disable-next-line no-process-env + delete process.env.LANGCHAIN_TEST_ENV_METADATA; + } + }); +}); + +describe("createSecretAnonymizer", () => { + const redact = createSecretAnonymizer(); + + // Sample secrets that match each rule (fake values, correct shapes). + const SAMPLES: Record = { + anthropic: "sk-ant-api03-AbCdEf0123456789AbCdEf0123456789xyz", + "openai-project": "sk-proj-abcdefghij1234567890ABCDEFGHIJ", + "openai-legacy": `sk-${"a".repeat(48)}`, + "langsmith-lsv2": `lsv2_pt_${"a".repeat(36)}_${"b".repeat(10)}`, + "langsmith-legacy": `ls__${"a".repeat(24)}`, + "github-pat": `ghp_${"A".repeat(36)}`, + "github-fine-grained": `github_pat_${"A".repeat(82)}`, + gitlab: `glpat-${"a".repeat(20)}`, + aws: "AKIAIOSFODNN7EXAMPLE", + "aws-a3t": `A3TX${"A".repeat(16)}`, + "google-api": `AIza${"A".repeat(35)}`, + "google-oauth": "ya29.A0ARrdaM-abcdefABCDEF1234567890_-", + // Assembled at runtime so no literal secret-shaped string sits in source + // (a repo secret-scanner would otherwise rewrite the fixture). + "slack-token": ["xoxb", "ABCDEFGHIJ0123456789xy"].join("-"), + "slack-app": `xapp-1-${"A".repeat(16)}`, + "slack-webhook": + "https://hooks.slack.com/services/T00000000/B00000000/abcdefABCDEF1234", + stripe: `sk_live_${"a".repeat(24)}`, + npm: `npm_${"a".repeat(36)}`, + pypi: `pypi-AgEIcHlwaS${"A".repeat(50)}`, + sendgrid: `SG.${"a".repeat(22)}.${"b".repeat(43)}`, + jwt: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.SflKxwRJSMeKKF2QT4fwpMeJf36", + }; + + test.each(Object.entries(SAMPLES))("redacts %s", (_name, secret) => { + const out = redact(`value is ${secret} end`) as string; + expect(out).not.toContain(secret); + expect(out).toContain(SECRET_PLACEHOLDER); + }); + + test("redacts PEM private key blocks", () => { + // Assembled from fragments so no literal key block sits in source + // (the repo secret-scanner rewrites contiguous PEM blocks). + const begin = ["-----BEGIN", "RSA", "PRIVATE", "KEY-----"].join(" "); + const end = ["-----END", "RSA", "PRIVATE", "KEY-----"].join(" "); + const pem = [begin, "a".repeat(64), end].join("\n"); + const out = redact({ file: pem }) as { file: string }; + expect(out.file).toBe(SECRET_PLACEHOLDER); + }); + + describe("structural rules", () => { + test("redacts the value of a sensitive assignment, keeps the name", () => { + const out = redact("MY_SERVICE_TOKEN=abcdef1234567890") as string; + expect(out).toBe(`MY_SERVICE_TOKEN=${SECRET_PLACEHOLDER}`); + }); + + test("redacts a quoted JSON secret value", () => { + const out = redact('{"api_key": "abcdef1234567890"}') as string; + expect(out).toBe(`{"api_key": "${SECRET_PLACEHOLDER}"}`); + }); + + test("redacts bare Bearer tokens", () => { + const out = redact("header: Bearer aB3xY7zQ1234567890") as string; + expect(out).toBe(`header: Bearer ${SECRET_PLACEHOLDER}`); + }); + + test("redacts the password in a connection string", () => { + const out = redact( + "postgres://user:sup3rs3cretpw@db.example.com:5432/app", + ) as string; + expect(out).toBe( + `postgres://user:${SECRET_PLACEHOLDER}@db.example.com:5432/app`, + ); + }); + }); + + describe("precision guards (must NOT be redacted)", () => { + const SAFE = [ + "123e4567-e89b-12d3-a456-426614174000", // UUID + "e83c5163316f89bfbde7d9ab23ca2e25604af290", // 40-char git SHA + "const total = computeSum(items) + 42;", // ordinary code + "The deployment finished successfully in 12 seconds.", // prose + "count=5", // short, non-sensitive assignment + 'description="a reasonably long human description"', // non-sensitive name + 'tokenizer: "cl100k_base"', // "token" must not match mid-word + "tokens_used: 123456", // keyword as a prefix of a longer word + ]; + test.each(SAFE)("leaves %s untouched", (value) => { + expect(redact(value)).toBe(value); + }); + }); + + test("redacts a multi-segment LangSmith v2 key including the tail", () => { + const key = `lsv2_pt_${"a".repeat(36)}_${"b".repeat(10)}`; + // Bare context (no assignment) so only the provider rule applies; exact + // equality catches any tail left visible past the placeholder. + expect(redact(`using ${key} now`)).toBe(`using ${SECRET_PLACEHOLDER} now`); + }); + + test("redacts a PGP private key block (KEY BLOCK armor)", () => { + const begin = ["-----BEGIN", "PGP", "PRIVATE", "KEY", "BLOCK-----"].join( + " ", + ); + const end = ["-----END", "PGP", "PRIVATE", "KEY", "BLOCK-----"].join(" "); + const block = [begin, "a".repeat(64), end].join("\n"); + expect((redact({ file: block }) as { file: string }).file).toBe( + SECRET_PLACEHOLDER, + ); + }); + + test("preserves the Bearer scheme in Authorization headers (parity)", () => { + expect(redact("Authorization: Bearer aB3xY7zQ1234567890")).toBe( + `Authorization: Bearer ${SECRET_PLACEHOLDER}`, + ); + }); + + test("redacts the credential after a scheme word in X-Api-Key", () => { + // The structural api-key rule must not stop at "Bearer" and leave the token. + const out = redact("X-Api-Key: Bearer tok_abcdefghij") as string; + expect(out).not.toContain("tok_abcdefghij"); + expect(out).toBe(`X-Api-Key: ${SECRET_PLACEHOLDER}`); + }); + + test("stops the redacted value at query-string separators", () => { + expect(redact("/api?api_key=ABCDEF123456&user=bob")).toBe( + `/api?api_key=${SECRET_PLACEHOLDER}&user=bob`, + ); + }); + + test("redacts the password in a connection string with empty username", () => { + expect(redact("redis://:sup3rs3cretpw@host:6379")).toBe( + `redis://:${SECRET_PLACEHOLDER}@host:6379`, + ); + }); + + test("redacts a lowercase bare bearer token, preserving the scheme word", () => { + expect(redact("sent bearer aB3xY7zQ1234567890 here")).toBe( + `sent bearer ${SECRET_PLACEHOLDER} here`, + ); + }); + + test("redacts secrets nested deep in a payload", () => { + const input = { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_call", + args: { command: `export OPENAI_API_KEY=sk-${"a".repeat(48)}` }, + }, + ], + }, + ], + }; + const out = redact(input) as typeof input; + const command = ( + out.messages[0].content[0] as { args: { command: string } } + ).args.command; + expect(command).toContain(SECRET_PLACEHOLDER); + expect(command).not.toContain("aaaa"); + }); + + test("extraRules are applied in addition to the defaults", () => { + const redactExtra = createSecretAnonymizer({ + extraRules: [ + { pattern: /INTERNAL-[0-9]{6}/g, replace: SECRET_PLACEHOLDER }, + ], + }); + expect(redactExtra("ticket INTERNAL-123456")).toBe( + `ticket ${SECRET_PLACEHOLDER}`, + ); + // Defaults still active. + expect(redactExtra(`key sk-ant-${"a".repeat(24)}`)).toContain( + SECRET_PLACEHOLDER, + ); + }); + + test("DEFAULT_SECRET_RULES all set an explicit replacement token", () => { + for (const rule of DEFAULT_SECRET_RULES) { + expect(rule.replace).toContain(SECRET_PLACEHOLDER); + } + }); + + test("applies through the Client before upload", async () => { + const anonymizer = createSecretAnonymizer(); + const { client, callSpy } = mockClient({ anonymizer }); + + const fn = traceable( + async (_input: Record) => ({ + note: `leaked sk-ant-${"a".repeat(30)} here`, + }), + { client, name: "fn", tracingEnabled: true }, + ); + + await fn({ apiKey: "AKIAIOSFODNN7EXAMPLE" }); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const data = tree.data["fn:0"] as { + inputs: Record; + outputs: Record; + }; + expect(JSON.stringify(data.inputs)).toContain(SECRET_PLACEHOLDER); + expect(JSON.stringify(data.inputs)).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(JSON.stringify(data.outputs)).toContain(SECRET_PLACEHOLDER); + }); +}); + +describe("traversal scaling", () => { + // No rule matches, so this times the walk itself, not the masking. + const anonymizer = createAnonymizer([ + { pattern: "no-such-secret-here", replace: "[redacted]" }, + ]); + + // Fastest of 3 runs: noise only ever adds time, so the min is the real cost. + const fastestMs = (n: number) => { + const payload = { + records: Array.from({ length: n }, (_, i) => ({ + id: `r${i}`, + text: "x".repeat(32), + })), + }; + + let best = Infinity; + for (let i = 0; i < 3; i += 1) { + const start = performance.now(); + anonymizer(payload); + best = Math.min(best, performance.now() - start); + } + return best; + }; + + test("8x the nodes costs ~8x the time, not ~64x", () => { + // With `queue.shift()`: 2k records 2.6ms, 16k records 121ms. + expect(fastestMs(16_000) / fastestMs(2_000)).toBeLessThan(20); + }); + + test("preserves BFS order, paths and maxDepth truncation", () => { + const seen: string[] = []; + const tap = (value: string, path?: string) => { + seen.push(`${path}=${value}`); + return value; + }; + const data = { a: "1", b: ["2", { c: "3" }], n: 7, ok: true }; + + createAnonymizer(tap)(data); + expect(seen).toEqual(["a=1", "b[0]=2", "b[1].c=3"]); + + seen.length = 0; + createAnonymizer(tap, { maxDepth: 2 })(data); + expect(seen).toEqual(["a=1", "b[0]=2"]); + }); +}); diff --git a/src/lib/tests/anthropic_usage.test.ts b/src/lib/tests/anthropic_usage.test.ts new file mode 100644 index 0000000..c286aad --- /dev/null +++ b/src/lib/tests/anthropic_usage.test.ts @@ -0,0 +1,98 @@ +import { convertAnthropicUsageToInputTokenDetails } from "../utils/usage.js"; +import { createUsageMetadata } from "../wrappers/anthropic.js"; + +describe("convertAnthropicUsageToInputTokenDetails", () => { + test("1h cache key has no trailing r", () => { + const result = convertAnthropicUsageToInputTokenDetails({ + cache_creation: { ephemeral_1h_input_tokens: 200 }, + }); + expect(result).toHaveProperty("ephemeral_1hr_input_tokens", 200); + }); + + test("legacy format maps to ephemeral_5m_input_tokens", () => { + const result = convertAnthropicUsageToInputTokenDetails({ + cache_creation_input_tokens: 7000, + }); + expect(result).toHaveProperty("ephemeral_5m_input_tokens", 7000); + }); + + test("cache_read_input_tokens maps to cache_read", () => { + const result = convertAnthropicUsageToInputTokenDetails({ + cache_read_input_tokens: 32000, + }); + expect(result).toHaveProperty("cache_read", 32000); + }); + + test("new format ephemeral_5m maps correctly", () => { + const result = convertAnthropicUsageToInputTokenDetails({ + cache_creation: { ephemeral_5m_input_tokens: 800 }, + }); + expect(result).toHaveProperty("ephemeral_5m_input_tokens", 800); + }); +}); + +describe("createUsageMetadata", () => { + test("no cache — passthrough", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = createUsageMetadata({ + input_tokens: 100, + output_tokens: 50, + } as any); + expect(result?.input_tokens).toBe(100); + expect(result?.output_tokens).toBe(50); + expect(result?.total_tokens).toBe(150); + expect(result?.input_token_details).toBeUndefined(); + }); + + test("legacy cache tokens are summed into input_tokens", () => { + // Anthropic sends input_tokens=19 (non-cached), cache tokens are additive. + const result = createUsageMetadata({ + input_tokens: 19, + output_tokens: 100, + cache_read_input_tokens: 32000, + cache_creation_input_tokens: 7000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(result?.input_tokens).toBe(39019); // 19 + 32000 + 7000 + expect(result?.output_tokens).toBe(100); + expect(result?.total_tokens).toBe(39119); // 39019 + 100 + expect(result?.input_token_details?.ephemeral_5m_input_tokens).toBe(7000); + expect(result?.input_token_details?.cache_read).toBe(32000); + }); + + test("new cache_creation object format", () => { + const result = createUsageMetadata({ + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 500, + cache_creation: { + ephemeral_5m_input_tokens: 800, + ephemeral_1h_input_tokens: 200, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(result?.input_tokens).toBe(1600); // 100 + 500 + 800 + 200 + expect(result?.output_tokens).toBe(50); + expect(result?.total_tokens).toBe(1650); // 1600 + 50 + expect(result?.input_token_details?.cache_read).toBe(500); + expect(result?.input_token_details?.ephemeral_5m_input_tokens).toBe(800); + expect(result?.input_token_details?.ephemeral_1hr_input_tokens).toBe(200); + }); + + test("only cache_read", () => { + const result = createUsageMetadata({ + input_tokens: 50, + output_tokens: 30, + cache_read_input_tokens: 1000, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + expect(result?.input_tokens).toBe(1050); // 50 + 1000 + expect(result?.output_tokens).toBe(30); + expect(result?.total_tokens).toBe(1080); // 1050 + 30 + }); + + test("returns undefined when usage is falsy", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(createUsageMetadata(null as any)).toBeUndefined(); + }); +}); diff --git a/src/lib/tests/batch_client.int.test.ts b/src/lib/tests/batch_client.int.test.ts new file mode 100644 index 0000000..ba9efbd --- /dev/null +++ b/src/lib/tests/batch_client.int.test.ts @@ -0,0 +1,294 @@ +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Client } from "../client.js"; +import { RunTree, convertToDottedOrderFormat } from "../run_trees.js"; +import { + deleteProject, + waitUntilProjectFound, + waitUntilRunFound, + skipOnRateLimit, +} from "./utils.js"; +import { traceable } from "../traceable.js"; +import { requiresClickhouse } from "./utils/markers.js"; + +requiresClickhouse.test( + "Test persist update run", + async () => { + await skipOnRateLimit(async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 2 }, + timeout_ms: 30_000, + }); + const projectName = + "__test_persist_update_run_batch_1" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime(), + runId, + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + }); + + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + await langchainClient.deleteProject({ projectName }); + }); + }, + 180_000, +); + +test.skip("Test persist update runs above the batch size limit", async () => { + await skipOnRateLimit(async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + batchSizeBytesLimit: 1, + timeout_ms: 30_000, + }); + const projectName = + "__test_persist_update_run_batch_above_bs_limit" + + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const createRun = async () => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime(), + runId, + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: Math.floor(new Date().getTime() / 1000), + }); + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + }; + + await Promise.all([createRun(), createRun(), createRun()]); + + await langchainClient.deleteProject({ projectName }); + }); +}, 180_000); + +requiresClickhouse.test( + "Test persist update run with delay", + async () => { + await skipOnRateLimit(async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + timeout_ms: 30_000, + }); + const projectName = + "__test_persist_update_run_batch_with_delay" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: Math.floor(new Date().getTime() / 1000), + }); + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + await langchainClient.deleteProject({ projectName }); + }); + }, + 180_000, +); + +requiresClickhouse.test( + "Test persist update run tree", + async () => { + await skipOnRateLimit(async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + timeout_ms: 30_000, + }); + const projectName = + "__test_persist_update_run_tree" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + const runId = uuidv4(); + const runTree = new RunTree({ + name: "Test Run Tree", + id: runId, + inputs: { input: "foo1" }, + client: langchainClient, + project_name: projectName, + }); + await runTree.postRun(); + await runTree.end({ output: "foo2" }); + await runTree.patchRun(); + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + expect(storedRun.dotted_order).toEqual(runTree.dotted_order); + expect(storedRun.trace_id).toEqual(runTree.trace_id); + expect(storedRun.inputs).toEqual({ input: "foo1" }); + expect(storedRun.outputs).toEqual({ output: "foo2" }); + await langchainClient.deleteProject({ projectName }); + }); + }, + 180_000, +); + +requiresClickhouse.test( + "Test persist run with attachment", + async () => { + await skipOnRateLimit(async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + timeout_ms: 30_000, + }); + const projectName = "__test_create_attachment" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + attachments: { + testimage: ["image/png", fs.readFileSync(pathname)], + }, + }); + + await langchainClient.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: Math.floor(new Date().getTime() / 1000), + }); + + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilProjectFound(langchainClient, projectName), + ]); + + const storedRun = await langchainClient.readRun(runId); + expect(storedRun.id).toEqual(runId); + await langchainClient.deleteProject({ projectName }); + }); + }, + 180_000, +); + +test.skip("very large runs", async () => { + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + timeout_ms: 120_000, + }); + + const projectName = "__test_large_runs" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + console.time("largeRunTimer"); + + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push( + traceable( + async () => { + return "x".repeat(9000000); + }, + { + project_name: projectName, + client: langchainClient, + tracingEnabled: true, + }, + )(), + ); + } + + await Promise.all(promises); + + console.timeLog("largeRunTimer"); + + await langchainClient.awaitPendingTraceBatches(); + + console.timeLog("largeRunTimer"); + + await Promise.all([waitUntilProjectFound(langchainClient, projectName)]); + + await langchainClient.deleteProject({ projectName }); +}, 180_000); diff --git a/src/lib/tests/batch_client.test.ts b/src/lib/tests/batch_client.test.ts new file mode 100644 index 0000000..a3a05d2 --- /dev/null +++ b/src/lib/tests/batch_client.test.ts @@ -0,0 +1,2422 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable prefer-const */ +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { jest, describe, expect, afterEach, it } from "@jest/globals"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { Client, mergeRuntimeEnvIntoRun } from "../client.js"; +import { convertToDottedOrderFormat } from "../run_trees.js"; +import type { KVMap, RunCreate } from "../schemas.js"; + +const parseMockRequestBody = async ( + body: string | Uint8Array | ReadableStream, +) => { + if (typeof body === "string") { + return JSON.parse(body); + } + + let rawMultipart; + if (body instanceof ReadableStream) { + // Clone the stream in case we need to retry without decompression + const [stream1, stream2] = body.tee(); + try { + rawMultipart = await new Response( + stream1.pipeThrough(new DecompressionStream("gzip")), + ).text(); + } catch { + // If gzip decompression fails, try reading as plain text + rawMultipart = await new Response(stream2).text(); + } + } else { + rawMultipart = new TextDecoder().decode(body); + } + + if (rawMultipart.trim().startsWith("{")) { + return JSON.parse(rawMultipart); + } + // Parse the multipart form data boundary from the raw text + const boundary = rawMultipart.split("\r\n")[0].trim(); + // Split the multipart body into individual parts + const parts = rawMultipart.split(boundary).slice(1, -1); + + const entries: [string, any][] = parts.map((part) => { + const [headers, ...contentParts] = part.trim().split("\r\n\r\n"); + const content = contentParts.join("\r\n\r\n"); + // Extract the name from Content-Disposition header + const nameMatch = headers.match(/name="([^"]+)"/); + const name = nameMatch ? nameMatch[1] : ""; + return [name, content.trim()]; + }); + const reconstructedBody: any = { + post: [], + patch: [], + }; + for (const [key, value] of entries) { + let [method, id, type] = key.split("."); + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch { + parsedValue = value; + } + if (!(method in reconstructedBody)) { + throw new Error(`${method} must be "post" or "patch"`); + } + if (!type) { + reconstructedBody[method as keyof typeof reconstructedBody].push( + parsedValue, + ); + } else { + for (const item of reconstructedBody[method]) { + if (item.id === id) { + item[type] = parsedValue; + } + } + } + } + return reconstructedBody; +}; + +// prettier-ignore +const ENDPOINT_TYPES = [ + "batch", + "multipart", +]; + +describe.each(ENDPOINT_TYPES)( + "Batch client tracing with %s endpoint", + (endpointType) => { + const extraBatchIngestConfig = + endpointType === "batch" + ? { + use_multipart_endpoint: false, + } + : { + use_multipart_endpoint: true, + }; + const extraInstanceFlags = { + gzip_body_enabled: true, + }; + const expectedTraceURL = + endpointType === "batch" + ? "https://api.smith.langchain.com/runs/batch" + : "https://api.smith.langchain.com/runs/multipart"; + + let testClients: any[] = []; + + const createClient = ( + config: any, + mockFetch?: jest.MockedFunction, + ) => { + const client = new Client({ + ...config, + fetchImplementation: mockFetch, + }); + testClients.push(client); + return client; + }; + + const createMockFetch = (callsArray: any[]) => { + return jest.fn((...args: any[]) => { + // Only count calls to batch/multipart endpoints, not info calls + if (args[0]?.includes("/runs/")) { + callsArray.push(args); + } + return Promise.resolve({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + }); + }; + + afterEach(async () => { + // Clean up all clients created during the test + for (const client of testClients) { + try { + await client.awaitPendingTraceBatches(); + } catch { + // Ignore cleanup errors + } + } + testClients = []; + + // Wait for any pending async operations to complete + await new Promise((resolve) => setTimeout(resolve, 100)); + jest.clearAllMocks(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it("should create a batched run with the given input", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + }); + + it("should hide inputs and outputs", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + hideInputs: () => ({ hidden: "inputs" }), + hideOutputs: () => ({ hidden: "outputs" }), + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + outputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + hidden: "inputs", + }, + outputs: { + hidden: "outputs", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + }); + + it("should hide inputs and outputs with an async function", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + hideInputs: async () => ({ hidden: "inputs" }), + hideOutputs: async () => ({ hidden: "outputs" }), + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + outputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + hidden: "inputs", + }, + outputs: { + hidden: "outputs", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + }); + + it.each([ + [true, {}], // should hide metadata + [false, { random: 123 }], // should keep metadata + [undefined, { random: 123 }], // should keep metadata + [ + function syncFn(metadata: KVMap) { + return { ...metadata, hidden: "metadata" }; + }, + { random: 123, hidden: "metadata" }, + ], // should hide metadata + [ + async function asyncFn(metadata: KVMap) { + return { ...metadata, hidden: "metadata" }; + }, + { random: 123, hidden: "metadata" }, + ], // should hide metadata + ])("should hide metadata: %p", async (hideMetadata, expectedMetadata) => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { apiKey: "test-api-key", autoBatchTracing: true, hideMetadata }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + outputs: { text: "hello world" }, + extra: { metadata: { random: 123 } }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + + const reqBody = await parseMockRequestBody(calledRequestParam?.body); + expect(reqBody).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + extra: { metadata: expectedMetadata, runtime: expect.any(Object) }, + }), + ], + patch: [], + }); + expect(reqBody.post[0].extra.metadata).toEqual(expectedMetadata); + }); + + it("should not throw an error if fetch fails for batch requests", async () => { + const calls: any[] = []; + const mockFetch = jest.fn((...args: any[]) => { + calls.push(args); + throw new Error("Totally expected mock error"); + }); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + }); + + it("Create + update batching should merge into a single call", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + const endTime = Math.floor(new Date().getTime() / 1000); + + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + outputs: { + output: ["Hi"], + }, + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + }); + + it("should preserve workspace override for auto-batched create and update", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + + const projectName = "__test_batch"; + const workspaceId = "override-workspace-id"; + const createRunId = uuidv4(); + const updateRunId = uuidv4(); + const { dottedOrder: createDottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + createRunId, + ); + const { dottedOrder: updateDottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + updateRunId, + ); + + await client.createRun( + { + id: createRunId, + project_name: projectName, + name: "test_create_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: createRunId, + dotted_order: createDottedOrder, + }, + { workspaceId }, + ); + + await client.updateRun( + updateRunId, + { + outputs: { output: ["Hi"] }, + dotted_order: updateDottedOrder, + trace_id: updateRunId, + }, + { workspaceId }, + ); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calledRequestParam.headers["x-tenant-id"]).toBe(workspaceId); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: createRunId, + trace_id: createRunId, + dotted_order: createDottedOrder, + }), + ], + patch: [ + expect.objectContaining({ + id: updateRunId, + outputs: { + output: ["Hi"], + }, + trace_id: updateRunId, + dotted_order: updateDottedOrder, + }), + ], + }); + }); + + it("should split runs with different workspaces into separate batches", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + workspaceId: "default-workspace-id", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + + const projectName = "__test_batch"; + const overrideWorkspaceId = "override-workspace-id"; + const defaultRunId = uuidv4(); + const overrideRunId = uuidv4(); + const { dottedOrder: defaultDottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + defaultRunId, + ); + const { dottedOrder: overrideDottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + overrideRunId, + ); + + // Uses the client's default workspace (no override). + await client.createRun({ + id: defaultRunId, + project_name: projectName, + name: "default_workspace_run", + run_type: "llm", + inputs: { text: "default" }, + trace_id: defaultRunId, + dotted_order: defaultDottedOrder, + }); + + // Overrides the workspace for this run only. + await client.createRun( + { + id: overrideRunId, + project_name: projectName, + name: "override_workspace_run", + run_type: "llm", + inputs: { text: "override" }, + trace_id: overrideRunId, + dotted_order: overrideDottedOrder, + }, + { workspaceId: overrideWorkspaceId }, + ); + + await client.awaitPendingTraceBatches(); + + // The two runs must land in two separate destination batches keyed by + // workspace, each carrying the correct x-tenant-id header. + expect(calls.length).toBe(2); + + const callsByTenant: Record = {}; + for (const [url, requestParam] of calls) { + expect(url).toBe(expectedTraceURL); + callsByTenant[requestParam.headers["x-tenant-id"]] = requestParam; + } + + expect(Object.keys(callsByTenant).sort()).toEqual( + ["default-workspace-id", overrideWorkspaceId].sort(), + ); + expect( + await parseMockRequestBody(callsByTenant["default-workspace-id"].body), + ).toEqual({ + post: [ + expect.objectContaining({ + id: defaultRunId, + trace_id: defaultRunId, + dotted_order: defaultDottedOrder, + }), + ], + patch: [], + }); + expect( + await parseMockRequestBody(callsByTenant[overrideWorkspaceId].body), + ).toEqual({ + post: [ + expect.objectContaining({ + id: overrideRunId, + trace_id: overrideRunId, + dotted_order: overrideDottedOrder, + }), + ], + patch: [], + }); + }); + + it("server info fetch should retry even if initial call fails", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + let serverInfoFailedOnce = false; + jest + .spyOn(client as any, "_ensureServerInfo") + .mockImplementationOnce(async () => { + serverInfoFailedOnce = true; + throw new Error("[MOCK] Connection error."); + }); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + const endTime = Math.floor(new Date().getTime() / 1000); + + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + await client.awaitPendingTraceBatches(); + + expect(serverInfoFailedOnce).toBe(true); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + outputs: { + output: ["Hi"], + }, + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + }); + + it("should immediately trigger a batch on root run end if blockOnRootRunFinalization is set", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + blockOnRootRunFinalization: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + // Wait for first batch to send + await new Promise((resolve) => setTimeout(resolve, 300)); + + const endTime = Math.floor(new Date().getTime() / 1000); + + // A root run finishing triggers the second batch + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + const runId2 = uuidv4(); + const { dottedOrder: dottedOrder2 } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId2, + ); + + // Will send in a third batch, even though it's triggered around the same time as the update + await client.createRun({ + id: runId2, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world 2" }, + trace_id: runId2, + dotted_order: dottedOrder2, + }); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + const calledRequestParam3: any = calls[2][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + expect(calls[2][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ + post: [], + patch: [ + expect.objectContaining({ + id: runId, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + outputs: { + output: ["Hi"], + }, + }), + ], + }); + expect(await parseMockRequestBody(calledRequestParam3?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId2, + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runId2, + dotted_order: dottedOrder2, + }), + ], + patch: [], + }); + + // Clean up any pending batches + await client.awaitPendingTraceBatches(); + }); + + it("should not trigger a batch on root run end and instead batch call with previous batch if blockOnRootRunFinalization is false", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + blockOnRootRunFinalization: false, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + expect((client as any).autoBatchQueue.items.length).toBe(1); + // Wait for first batch to send + await new Promise((resolve) => setTimeout(resolve, 300)); + expect((client as any).autoBatchQueue.items.length).toBe(0); + + const endTime = Math.floor(new Date().getTime() / 1000); + + // Start the the second batch + await client.updateRun(runId, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + const runId2 = uuidv4(); + const { dottedOrder: dottedOrder2 } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId2, + ); + + // Should aggregate on the second batch + await client.createRun({ + id: runId2, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world 2" }, + trace_id: runId2, + dotted_order: dottedOrder2, + }); + + // 2 runs in the queue + expect((client as any).autoBatchQueue.items.length).toBe(2); + await client.awaitPendingTraceBatches(); + expect((client as any).autoBatchQueue.items.length).toBe(0); + + expect(calls.length).toEqual(2); + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: "hello world", + }, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId2, + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runId2, + dotted_order: dottedOrder2, + }), + ], + patch: [ + expect.objectContaining({ + id: runId, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + outputs: { + output: ["Hi"], + }, + }), + ], + }); + + // Clean up any pending batches + await client.awaitPendingTraceBatches(); + }); + + it("should send traces above the batch size and see even batches", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + batchSizeBytesLimit: 10000, + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(15)].map(async (_, i) => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const params = mergeRuntimeEnvIntoRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + } as RunCreate); + // Allow some extra space for other request properties + const mockRunSize = 950; + const padCount = mockRunSize - JSON.stringify(params).length; + params.inputs.text = params.inputs.text + "x".repeat(padCount); + await client.createRun(params); + return runId; + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + + const firstBatchBody = await parseMockRequestBody( + calledRequestParam?.body, + ); + const secondBatchBody = await parseMockRequestBody( + calledRequestParam2?.body, + ); + + const initialBatchBody = + firstBatchBody.post.length === 10 ? firstBatchBody : secondBatchBody; + const followupBatchBody = + firstBatchBody.post.length === 10 ? secondBatchBody : firstBatchBody; + + // Queue should drain as soon as size limit is reached, + // sending both batches + expect(initialBatchBody).toEqual({ + post: runIds.slice(0, 10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + i), + }, + trace_id: runId, + }), + ), + patch: [], + }); + + expect(followupBatchBody).toEqual({ + post: runIds.slice(10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + (i + 10)), + }, + trace_id: runId, + }), + ), + patch: [], + }); + }); + + it("should send more traces than the size limit and see even batches", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + batchSizeBytesLimit: 1000000, + batchSizeLimit: 5, + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(9)].map(async (_, i) => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const params = mergeRuntimeEnvIntoRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + } as RunCreate); + await client.createRun(params); + return runId; + }), + ); + + await client.awaitPendingTraceBatches(); + + expect(calls.length).toBe(2); + + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + + const firstBatchBody = await parseMockRequestBody( + calledRequestParam?.body, + ); + const secondBatchBody = await parseMockRequestBody( + calledRequestParam2?.body, + ); + + const initialBatchBody = + firstBatchBody.post.length === 5 ? firstBatchBody : secondBatchBody; + const followupBatchBody = + firstBatchBody.post.length === 5 ? secondBatchBody : firstBatchBody; + + // Queue should drain as soon as size limit is reached, + // sending both batches + expect(initialBatchBody).toEqual({ + post: runIds.slice(0, 5).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + i), + }, + trace_id: runId, + }), + ), + patch: [], + }); + + expect(followupBatchBody).toEqual({ + post: runIds.slice(5).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + (i + 5)), + }, + trace_id: runId, + }), + ), + patch: [], + }); + }); + + it("should sample and see proper batching", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + tracingSamplingRate: 0.5, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Two of these are sampled in at rate 0.5 and two are filtered out + // (XXH3-128 buckets: 10679, 87881, 769129, 982794), so the batch below + // exercises both paths. Keep the mix if these are ever changed. + const sampledIds = [ + "00000000-0000-0000-0000-000000000015", + "00000000-0000-0000-0000-000000000009", + "00000000-0000-0000-0000-000000000001", + "00000000-0000-0000-0000-000000000004", + ]; + const childIds = [ + "00000000-0000-0000-0000-000000000010", + "00000000-0000-0000-0000-000000000011", + "00000000-0000-0000-0000-000000000012", + "00000000-0000-0000-0000-000000000013", + ]; + const isSampled = (runId?: string) => + (client as any)._shouldSample(runId); + + const runParams = await Promise.all( + sampledIds.map(async (runId, i) => { + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const params = mergeRuntimeEnvIntoRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + } as RunCreate); + await client.createRun(params); + return params; + }), + ); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = calls[0][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + const batchBody = await parseMockRequestBody(calledRequestParam?.body); + + const childRunParams = await Promise.all( + runParams.map(async (runParam, i) => { + const runId = childIds[i]; + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const params = mergeRuntimeEnvIntoRun({ + id: runId, + project_name: projectName, + name: "test_child_run " + i, + run_type: "llm", + inputs: { text: "child world " + i }, + trace_id: runParam.id, + dotted_order: [runParam.dotted_order, dottedOrder].join("."), + } as RunCreate); + await client.createRun(params); + await client.updateRun(runParam.id!, { + outputs: { output: ["Hi"] }, + dotted_order: dottedOrder, + trace_id: runParam.id, + end_time: Math.floor(new Date().getTime() / 1000), + }); + return params; + }), + ); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam2: any = calls[1][1]; + expect(calls[1][0]).toBe(expectedTraceURL); + + const batchBody2 = await parseMockRequestBody(calledRequestParam2?.body); + + expect(batchBody).toEqual({ + post: runParams + .map((runParam, i) => { + if (isSampled(runParam.id)) { + return expect.objectContaining({ + id: runParam.id, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + i), + }, + trace_id: runParam.id, + }); + } else { + return undefined; + } + }) + .filter((item) => item !== undefined), + patch: [], + }); + + const expectedPosts = childRunParams + .map((childRunParam, i) => { + // Sampling keys on trace_id, so a child follows its root's decision. + if (isSampled(runParams[i].id)) { + return expect.objectContaining({ + id: childRunParam.id, + run_type: "llm", + inputs: { + text: expect.stringContaining("child world " + i), + }, + trace_id: runParams[i].id, + }); + } else { + return undefined; + } + }) + .filter((item) => item !== undefined); + const expectedPatches = runParams + .map((runParam) => { + if (isSampled(runParam.id)) { + return expect.objectContaining({ + id: runParam.id, + trace_id: runParam.id, + }); + } else { + return undefined; + } + }) + .filter((item) => item !== undefined); + expect(batchBody2.post).toHaveLength(expectedPosts.length); + expect(batchBody2.post).toEqual(expect.arrayContaining(expectedPosts)); + expect(batchBody2.patch).toHaveLength(expectedPatches.length); + expect(batchBody2.patch).toEqual(expect.arrayContaining(expectedPatches)); + }); + + it("should flush traces in batches with manualFlushMode enabled", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + batchSizeBytesLimit: 10000, + autoBatchTracing: true, + manualFlushMode: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(15)].map(async (_, i) => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const params = mergeRuntimeEnvIntoRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + } as RunCreate); + // Allow some extra space for other request properties + const mockRunSize = 950; + const padCount = mockRunSize - JSON.stringify(params).length; + params.inputs.text = params.inputs.text + "x".repeat(padCount); + await client.createRun(params); + return runId; + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + expect(calls.length).toBe(0); + + await client.flush(); + + expect(calls.length).toBe(2); + + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + + const firstBatchBody = await parseMockRequestBody( + calledRequestParam?.body, + ); + const secondBatchBody = await parseMockRequestBody( + calledRequestParam2?.body, + ); + + const initialBatchBody = + firstBatchBody.post.length === 10 ? firstBatchBody : secondBatchBody; + const followupBatchBody = + firstBatchBody.post.length === 10 ? secondBatchBody : firstBatchBody; + + // Queue should drain as soon as size limit is reached, + // sending both batches + expect(initialBatchBody).toEqual({ + post: runIds.slice(0, 10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + i), + }, + trace_id: runId, + }), + ), + patch: [], + }); + + expect(followupBatchBody).toEqual({ + post: runIds.slice(10).map((runId, i) => + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + text: expect.stringContaining("hello world " + (i + 10)), + }, + trace_id: runId, + }), + ), + patch: [], + }); + }); + + it("a very low batch size limit should be equivalent to single calls", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + batchSizeBytesLimit: 1, + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { + ...extraBatchIngestConfig, + }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + const runIds = await Promise.all( + [...Array(4)].map(async (_, i) => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "hello world " + i }, + trace_id: runId, + dotted_order: dottedOrder, + }); + return runId; + }), + ); + + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(calls.length).toEqual(4); + + const calledRequestParam: any = calls[0][1]; + const calledRequestParam2: any = calls[1][1]; + const calledRequestParam3: any = calls[2][1]; + const calledRequestParam4: any = calls[3][1]; + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + expect(calls[2][0]).toBe(expectedTraceURL); + expect(calls[3][0]).toBe(expectedTraceURL); + + // Queue should drain as soon as byte size limit of 1 is reached, + // sending each call individually + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[0], + run_type: "llm", + inputs: { + text: "hello world 0", + }, + trace_id: runIds[0], + }), + ], + patch: [], + }); + + expect(await parseMockRequestBody(calledRequestParam2?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[1], + run_type: "llm", + inputs: { + text: "hello world 1", + }, + trace_id: runIds[1], + }), + ], + patch: [], + }); + + expect(await parseMockRequestBody(calledRequestParam3?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[2], + run_type: "llm", + inputs: { + text: "hello world 2", + }, + trace_id: runIds[2], + }), + ], + patch: [], + }); + + expect(await parseMockRequestBody(calledRequestParam4?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runIds[3], + run_type: "llm", + inputs: { + text: "hello world 3", + }, + trace_id: runIds[3], + }), + ], + patch: [], + }); + }); + + it("Use multipart endpoint if info call fails", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest + .spyOn(client as any, "_ensureServerInfo") + .mockResolvedValue(undefined); + const projectName = "__test_batch"; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = calls[0][1]; + + expect( + await parseMockRequestBody(calledRequestParam?.body), + ).toMatchObject({ + post: [ + { + id: runId, + session_name: projectName, + extra: expect.anything(), + start_time: expect.any(Number), + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }, + ], + patch: [], + }); + // When server info is unavailable, client defaults to multipart endpoint (the new default) + expect(calls[0][0]).toBe( + "https://api.smith.langchain.com/runs/multipart", + ); + + expect(calls.length).toBe(1); + }); + + it("Should handle circular values", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + const a: Record = {}; + const b: Record = {}; + a.b = b; + b.a = a; + + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: a, + trace_id: runId, + dotted_order: dottedOrder, + }); + + const endTime = Math.floor(new Date().getTime() / 1000); + + await client.updateRun(runId, { + outputs: b, + dotted_order: dottedOrder, + trace_id: runId, + end_time: endTime, + }); + + await client.awaitPendingTraceBatches(); + + const calledRequestParam: any = calls[0][1]; + expect(await parseMockRequestBody(calledRequestParam?.body)).toEqual({ + post: [ + expect.objectContaining({ + id: runId, + run_type: "llm", + inputs: { + b: { + a: { + result: "[Circular]", + }, + }, + }, + outputs: { + a: + // Stringification happens at a different level + endpointType === "batch" + ? { + result: "[Circular]", + } + : { + b: { + result: "[Circular]", + }, + }, + }, + end_time: endTime, + trace_id: runId, + dotted_order: dottedOrder, + }), + ], + patch: [], + }); + expect(calls[0][0]).toBe(expectedTraceURL); + + expect(calls.length).toBe(1); + }); + + it("should retry multipart requests on 5xx errors", async () => { + const calls: any[] = []; + let callCount = 0; + + const mockFetch = jest.fn((...args: any[]) => { + callCount++; + // Only count calls to multipart endpoints, not info calls + if (args[0]?.includes("/runs/")) { + calls.push(args); + + // Fail first two attempts with 500, succeed on third + if (callCount <= 2) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: () => Promise.resolve("Server error"), + } as Response); + } + } + + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + } as Response); + }); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + + const projectName = "__test_batch"; + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await client.awaitPendingTraceBatches(); + + // Should have made 3 calls (2 failures + 1 success) + expect(calls.length).toBe(3); + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + expect(calls[2][0]).toBe(expectedTraceURL); + }); + + it("should not retry multipart requests on 422 errors", async () => { + const calls: any[] = []; + + const mockFetch = jest.fn((...args: any[]) => { + // Only count calls to multipart endpoints, not info calls + if (args[0]?.includes("/runs/")) { + calls.push(args); + + // Always return 422 (should not retry) + return Promise.resolve({ + ok: false, + status: 422, + statusText: "Unprocessable Entity", + text: () => Promise.resolve("Validation error"), + } as Response); + } + + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + } as Response); + }); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + + const projectName = "__test_batch"; + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + // Wait for batch processing to complete (and fail) + await client.awaitPendingTraceBatches(); + + // Should have made only 1 call (no retries for 422) + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe(expectedTraceURL); + }); + + it("should retry multipart requests on 429 errors", async () => { + const calls: any[] = []; + let callCount = 0; + + const mockFetch = jest.fn((...args: any[]) => { + callCount++; + // Only count calls to multipart endpoints, not info calls + if (args[0]?.includes("/runs/")) { + calls.push(args); + + // Fail first two attempts with 429, succeed on third + if (callCount <= 2) { + return Promise.resolve({ + ok: false, + status: 429, + statusText: "Too Many Requests", + text: () => Promise.resolve("Rate limited"), + headers: { + get: (name: string) => (name === "retry-after" ? "1" : null), + }, + } as Response); + } + } + + return Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + } as Response); + }); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + }, + mockFetch, + ); + + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + + const projectName = "__test_batch"; + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + }); + + await client.awaitPendingTraceBatches(); + + // Should have made 3 calls (2 failures + 1 success) + expect(calls.length).toBe(3); + expect(calls[0][0]).toBe(expectedTraceURL); + expect(calls[1][0]).toBe(expectedTraceURL); + expect(calls[2][0]).toBe(expectedTraceURL); + }); + + it("should drop runs when maxIngestMemoryBytes is exceeded in AutoBatchQueue", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + const consoleSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + // Set a very low queue size limit (1000 bytes) + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + maxIngestMemoryBytes: 1000, + manualFlushMode: true, // Prevent auto-flushing so we can test the limit + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Create runs until we exceed the queue size limit + const runIds: string[] = []; + const createPromises: Promise[] = []; + for (let i = 0; i < 10; i++) { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + const promise = client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "x".repeat(200) }, // Make each run ~200+ bytes + trace_id: runId, + dotted_order: dottedOrder, + }); + createPromises.push(promise); + runIds.push(runId); + } + + // All promises should resolve immediately without blocking, even dropped ones + await Promise.all(createPromises); + + // Should have warned about dropped runs + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("AutoBatchQueue size limit"), + ); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Dropping run"), + ); + + // Clean up + consoleSpy.mockRestore(); + await client.flush(); + }); + + it("should allow a single large run that exceeds the queue size limit", async () => { + const calls: any[] = []; + const mockFetch = createMockFetch(calls); + const consoleSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + // Set a low queue size limit (1000 bytes) + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + maxIngestMemoryBytes: 1000, + manualFlushMode: true, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Create a single large run that exceeds the limit (queue is empty) + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "large_run", + run_type: "llm", + inputs: { text: "x".repeat(2000) }, // 2000+ bytes, exceeds 1000 byte limit + trace_id: runId, + dotted_order: dottedOrder, + }); + + // Should NOT have warned since the queue was empty + expect(consoleSpy).not.toHaveBeenCalledWith( + expect.stringContaining("AutoBatchQueue size limit"), + ); + + // The run should be in the queue + expect((client as any).autoBatchQueue.items.length).toBe(1); + expect((client as any).autoBatchQueue.sizeBytes).toBeGreaterThan(1000); + + // Clean up + consoleSpy.mockRestore(); + await client.flush(); + }); + + it("should drop batches when maxIngestMemoryBytes is exceeded", async () => { + const calls: any[] = []; + let callCount = 0; + const mockFetch = jest.fn( + async (url: string | URL | Request, init?: RequestInit) => { + calls.push([url, init]); + callCount++; + // Block first few requests to simulate slow network and cause queue buildup + if (callCount <= 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + return { + ok: true, + status: 200, + json: async () => ({}), + text: async () => "{}", + } as Response; + }, + ); + + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + // Set a low maxIngestMemoryBytes limit (2000 bytes) + // This will allow ~4-5 batches before dropping + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + maxIngestMemoryBytes: 2000, + traceBatchConcurrency: 1, // Low concurrency to cause queue buildup + batchSizeBytesLimit: 500, // Small batches + batchSizeLimit: 3, // Few items per batch + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Create many runs to trigger multiple batches + const runIds: string[] = []; + for (let i = 0; i < 20; i++) { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "x".repeat(200) }, // Make each run ~200+ bytes + trace_id: runId, + dotted_order: dottedOrder, + }); + runIds.push(runId); + } + + // Wait a bit for batches to start processing + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Should have rejected some batches due to queue size limit + // Errors can appear in either console.error (from _processBatch) or console.warn (from _sendMultipartRequest) + const errorCalled = consoleErrorSpy.mock.calls.some( + (call) => + call[0]?.includes?.("Error exporting batch") && + call[1]?.message?.includes?.("Queue size limit"), + ); + const warnCalled = consoleWarnSpy.mock.calls.some( + (call) => call[0]?.includes?.("Queue size limit"), + ); + + expect(errorCalled || warnCalled).toBe(true); + + // Clean up + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + await client.awaitPendingTraceBatches(); + }); + + it("should decrement queue size after batches complete", async () => { + const calls: any[] = []; + let completedCalls = 0; + const mockFetch = jest.fn( + async (url: string | URL | Request, init?: RequestInit) => { + calls.push([url, init]); + await new Promise((resolve) => setTimeout(resolve, 50)); + completedCalls++; + return { + ok: true, + status: 200, + json: async () => ({}), + text: async () => "{}", + } as Response; + }, + ); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + maxIngestMemoryBytes: 10000, + traceBatchConcurrency: 2, + batchSizeBytesLimit: 500, + batchSizeLimit: 3, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Create runs to build up queue + for (let i = 0; i < 10; i++) { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "x".repeat(200) }, + trace_id: runId, + dotted_order: dottedOrder, + }); + } + + // Wait for some batches to start processing + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check that queue size is non-zero while processing + const queueSizeDuringProcessing = (client as any).batchIngestCaller + .queueSizeBytes; + expect(queueSizeDuringProcessing).toBeGreaterThan(0); + + // Wait for all batches to complete + await client.awaitPendingTraceBatches(); + + // Queue size should be zero after all complete + const queueSizeAfterCompletion = (client as any).batchIngestCaller + .queueSizeBytes; + expect(queueSizeAfterCompletion).toBe(0); + expect(completedCalls).toBeGreaterThan(0); + }); + + it("should decrement queue size even when requests timeout", async () => { + const calls: any[] = []; + const mockFetch = jest.fn( + async ( + url: string | URL | Request, + init?: RequestInit, + ): Promise => { + calls.push([url, init]); + // Respect the abort signal from timeout + const signal = init?.signal; + if (signal) { + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + resolve({ + ok: true, + status: 200, + json: async () => ({}), + text: async () => "{}", + } as Response); + }, 120000); + + signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + reject(new Error("TimeoutError: The operation was aborted")); + }); + }); + } + return { + ok: true, + status: 200, + json: async () => ({}), + text: async () => "{}", + } as Response; + }, + ); + + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + const client = createClient( + { + apiKey: "test-api-key", + autoBatchTracing: true, + maxIngestMemoryBytes: 10000, + timeout_ms: 100, // Very short timeout to trigger quickly + traceBatchConcurrency: 2, + batchSizeBytesLimit: 500, + batchSizeLimit: 3, + }, + mockFetch, + ); + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { ...extraBatchIngestConfig }, + instance_flags: { ...extraInstanceFlags }, + }); + const projectName = "__test_batch"; + + // Create runs that will timeout + for (let i = 0; i < 5; i++) { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + project_name: projectName, + name: "test_run " + i, + run_type: "llm", + inputs: { text: "x".repeat(200) }, + trace_id: runId, + dotted_order: dottedOrder, + }); + } + + // Wait for requests to timeout and batches to complete (with errors) + await client.awaitPendingTraceBatches(); + + // Queue size should be zero even though requests timed out + const queueSizeAfterTimeout = (client as any).batchIngestCaller + .queueSizeBytes; + expect(queueSizeAfterTimeout).toBe(0); + + // Should have logged timeout errors (in either console.error or console.warn) + const errorCalled = consoleErrorSpy.mock.calls.some( + (call) => + call[0]?.includes?.("Error exporting batch") && + call[1]?.message?.includes?.("TimeoutError"), + ); + const warnCalled = consoleWarnSpy.mock.calls.some( + (call) => call[0]?.includes?.("TimeoutError"), + ); + + expect(errorCalled || warnCalled).toBe(true); + + consoleErrorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + }); + }, +); + +describe("sample rate and caller metadata survive batching and compression", () => { + let testClients: Client[] = []; + + afterEach(async () => { + for (const client of testClients) { + try { + await client.awaitPendingTraceBatches(); + } catch { + // Ignore cleanup errors + } + } + testClients = []; + jest.clearAllMocks(); + }); + + interface CapturedRequestInit { + headers: Record; + body: string | Uint8Array | ReadableStream; + } + + interface ParsedRun { + id: string; + extra: { metadata: Record }; + } + + interface ParsedBatchBody { + post: ParsedRun[]; + patch: ParsedRun[]; + } + + const createMockFetch = ( + callsArray: Array<[string, CapturedRequestInit]>, + ): jest.MockedFunction => + jest.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === "string" + ? input + : "url" in input + ? input.url + : input.toString(); + if (url.includes("/runs/") && init) { + // `_sendMultipartRequest` always passes a plain `Record` + // headers object and a string/Uint8Array/ReadableStream body; narrow the + // library-supplied `RequestInit` to the shape read below. + callsArray.push([url, init as unknown as CapturedRequestInit]); + } + return { ok: true, text: () => Promise.resolve("") } as Response; + }) as jest.MockedFunction; + + const createSampledClient = ( + mockFetch: jest.MockedFunction, + gzipBodyEnabled: boolean, + ) => { + const client = new Client({ + apiKey: "test-api-key", + autoBatchTracing: true, + tracingSamplingRate: 1, + fetchImplementation: mockFetch, + }); + testClients.push(client); + // `_ensureServerInfo` is private; the cast below narrows just enough to spy on it. + jest + .spyOn( + client as unknown as { _ensureServerInfo: () => Promise }, + "_ensureServerInfo", + ) + .mockResolvedValue({ + version: "foo", + batch_ingest_config: { use_multipart_endpoint: true }, + instance_flags: { gzip_body_enabled: gzipBodyEnabled }, + }); + return client; + }; + + const postSampledRun = async ( + client: Client, + userId: string, + ): Promise => { + const runId = uuidv4(); + const { dottedOrder } = convertToDottedOrderFormat( + new Date().getTime() / 1000, + runId, + ); + await client.createRun({ + id: runId, + name: "test_run", + run_type: "llm", + inputs: { text: "hello world" }, + trace_id: runId, + dotted_order: dottedOrder, + extra: { metadata: { user_id: userId } }, + }); + return runId; + }; + + const expectSampleRateAndUserId = ( + reqBody: ParsedBatchBody, + runIdA: string, + runIdB: string, + ) => { + const byId = Object.fromEntries(reqBody.post.map((run) => [run.id, run])); + expect(byId[runIdA].extra.metadata.ls_tracing_sample_rate).toBe(1); + expect(byId[runIdA].extra.metadata.user_id).toBe("user-a"); + expect(byId[runIdB].extra.metadata.ls_tracing_sample_rate).toBe(1); + expect(byId[runIdB].extra.metadata.user_id).toBe("user-b"); + }; + + it("should report the sample rate and caller metadata through the uncompressed multipart batch path", async () => { + const calls: Array<[string, CapturedRequestInit]> = []; + const mockFetch = createMockFetch(calls); + const client = createSampledClient(mockFetch, false); + + const runIdA = await postSampledRun(client, "user-a"); + const runIdB = await postSampledRun(client, "user-b"); + + await client.awaitPendingTraceBatches(); + + expect(calls.length).toBe(1); + const requestParam = calls[0][1]; + expect(requestParam.headers["Content-Encoding"]).toBeUndefined(); + + // `parseMockRequestBody` is untyped; the shape asserted here is what it + // reconstructs from the multipart body (see helper at the top of this file). + const reqBody = (await parseMockRequestBody( + requestParam.body, + )) as unknown as ParsedBatchBody; + expectSampleRateAndUserId(reqBody, runIdA, runIdB); + }); + + it("should report the sample rate and caller metadata through the gzip-compressed multipart batch path", async () => { + const calls: Array<[string, CapturedRequestInit]> = []; + const mockFetch = createMockFetch(calls); + const client = createSampledClient(mockFetch, true); + + const runIdA = await postSampledRun(client, "user-a"); + const runIdB = await postSampledRun(client, "user-b"); + + await client.awaitPendingTraceBatches(); + + expect(calls.length).toBe(1); + const requestParam = calls[0][1]; + // Proves the request actually went through CompressionStream("gzip"), + // not just that the parser's decompress-or-fall-back branch happened to work. + expect(requestParam.headers["Content-Encoding"]).toBe("gzip"); + + const reqBody = (await parseMockRequestBody( + requestParam.body, + )) as unknown as ParsedBatchBody; + expectSampleRateAndUserId(reqBody, runIdA, runIdB); + }); +}); diff --git a/src/lib/tests/bun/traceable.test.bun.ts b/src/lib/tests/bun/traceable.test.bun.ts new file mode 100644 index 0000000..06b886e --- /dev/null +++ b/src/lib/tests/bun/traceable.test.bun.ts @@ -0,0 +1,31 @@ +// @ts-expect-error Bun types are not imported due to conflicts with Node types +import { test, expect } from "bun:test"; +import { traceable } from "../../traceable.js"; +import { Client } from "../../client.js"; +import { waitUntilRunFound } from "../utils.js"; +import { v7 as uuidv7 } from "../../utils/uuid/src/index.js"; + +test("Simple Bun tracing integration test", async () => { + const client = new Client(); + const runId = uuidv7(); + + await traceable( + async (input: { text: string }) => { + return { out: input.text }; + }, + { + name: "bun_tracing_test", + client, + id: runId, + }, + )({ text: "Hello world" }); + + // Wait for the run to be found in the backend + await waitUntilRunFound(client, runId, true, 90_000); + + const storedRun = await client.readRun(runId); + expect(storedRun.id).toEqual(runId); + expect(storedRun.name).toEqual("bun_tracing_test"); + expect(storedRun.inputs).toEqual({ text: "Hello world" }); + expect(storedRun.outputs).toEqual({ out: "Hello world" }); +}, 90_000); diff --git a/src/lib/tests/claude_agent_sdk.int.test.ts b/src/lib/tests/claude_agent_sdk.int.test.ts new file mode 100644 index 0000000..a40e601 --- /dev/null +++ b/src/lib/tests/claude_agent_sdk.int.test.ts @@ -0,0 +1,898 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import { beforeAll, test, expect } from "@jest/globals"; +import * as claudeSDK from "@anthropic-ai/claude-agent-sdk"; +import { z } from "zod"; +import { wrapClaudeAgentSDK } from "../experimental/anthropic/index.js"; +import { traceable } from "../traceable.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { requiresProvider } from "./utils/markers.js"; +const { describe } = requiresProvider; + +// Note: These tests require an ANTHROPIC_API_KEY environment variable. +// They are skipped by default to avoid requiring API keys in CI. +// +// To run these tests: +// 1. Set ANTHROPIC_API_KEY environment variable +// 2. Remove .skip from the describe block + +describe("wrapClaudeAgentSDK - Real API Integration", () => { + const wrappedSDK = wrapClaudeAgentSDK(claudeSDK, { + project_name: "claude-agent-sdk-test", + tags: ["integration-test"], + }) as typeof claudeSDK; + + beforeAll(() => { + if (!process.env.ANTHROPIC_API_KEY) { + throw new Error("ANTHROPIC_API_KEY environment variable is required"); + } + }); + + const WEATHER_PROMPT = + "You have a get_weather tool. When the user asks about weather, call it. Only call the tool once."; + + const allowAllTools = async ( + _toolName: string, + input: Record, + ) => ({ + behavior: "allow" as const, + updatedInput: input, + }); + + const consumeQuery = async ( + query: AsyncIterable, + ): Promise => { + const messages: any[] = []; + for await (const message of query) { + messages.push(message); + } + return messages; + }; + + const toolUseNames = (messages: any[]) => + messages.flatMap((message) => + message.type === "assistant" && Array.isArray(message.message?.content) + ? message.message.content + .filter((block: any) => block.type === "tool_use") + .map((block: any) => block.name) + : [], + ); + + const stringifyToolResultContent = (content: any): string => { + if (typeof content === "string") return content; + if (content == null) return ""; + if (Array.isArray(content)) { + return content.map(stringifyToolResultContent).join("\n"); + } + if (typeof content === "object") { + if (typeof content.text === "string") return content.text; + try { + return JSON.stringify(content); + } catch { + return String(content); + } + } + return String(content); + }; + + const toolResultContents = (messages: any[]) => + messages.flatMap((message) => + message.type === "user" && Array.isArray(message.message?.content) + ? message.message.content + .filter((block: any) => block.type === "tool_result") + .map((block: any) => stringifyToolResultContent(block.content)) + : [], + ); + + test("query with simple prompt", async () => { + const messages: any[] = []; + + for await (const message of wrappedSDK.query({ + prompt: "Say 'Hello from LangSmith!' and nothing else.", + options: { + model: "haiku", + maxTurns: 1, + }, + })) { + // Debug: log each message type and key properties + const msg = message as any; + console.log("Message:", { + type: msg.type, + id: msg.message?.id || msg.uuid, + hasMessage: !!msg.message, + messageModel: msg.message?.model, + messageUsage: msg.message?.usage, + usage: msg.usage, + model: msg.model, + stopReason: msg.message?.stop_reason, + }); + messages.push(message); + } + + // Log summary + console.log("Total messages:", messages.length); + console.log( + "Message types:", + messages.map((m) => m.type), + ); + + // Should have at least assistant message and result message + expect(messages.length).toBeGreaterThan(0); + + // Find assistant message + const assistantMessage = messages.find((m) => m.type === "assistant"); + expect(assistantMessage).toBeDefined(); + expect(assistantMessage.message?.content).toBeDefined(); + + // Find result message + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage).toBeDefined(); + expect(resultMessage.usage).toBeDefined(); + expect(resultMessage.usage.input_tokens).toBeGreaterThan(0); + expect(resultMessage.usage.output_tokens).toBeGreaterThan(0); + }); + + test("query with subagent usage", async () => { + const messages: any[] = []; + + // Configure a subagent that can do calculations + for await (const message of wrappedSDK.query({ + prompt: + "I need to calculate 25 * 4 + 100. Please delegate this to the calculator subagent.", + options: { + model: "haiku", + maxTurns: 10, + agents: { + calculator: { + description: "A subagent that performs mathematical calculations", + prompt: + "You are a calculator assistant. Perform the requested calculation and return the result.", + tools: [], + model: "haiku", + }, + }, + }, + })) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Verify we have assistant messages + const assistantMessages = messages.filter((m) => m.type === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); + + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage).toBeDefined(); + expect(resultMessage.usage).toBeDefined(); + expect(resultMessage.modelUsage).toBeDefined(); + + // Verify token usage + expect(resultMessage.usage.output_tokens).toBeGreaterThan(0); + expect(resultMessage.usage.input_tokens).toBeGreaterThan(0); + }); + + test("query with MCP tool usage", async () => { + const calculator = wrappedSDK.tool( + "calculator", + "Performs basic arithmetic operations", + { + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number(), + b: z.number(), + }, + async (args: any) => { + let result: number; + switch (args.operation) { + case "add": + result = args.a + args.b; + break; + case "subtract": + result = args.a - args.b; + break; + case "multiply": + result = args.a * args.b; + break; + case "divide": + result = args.a / args.b; + break; + default: + throw new Error("Unknown operation"); + } + return { + content: [{ type: "text", text: `Result: ${result}` }], + }; + }, + ); + + // Create SDK MCP server with the tool + const mcpServer = wrappedSDK.createSdkMcpServer({ + name: "calculator-server", + tools: [calculator], + }); + + const messages: any[] = []; + + for await (const message of wrappedSDK.query({ + prompt: + "Use the calculator tool to compute 42 + 17, then tell me the result.", + options: { + model: "haiku", + maxTurns: 5, + allowDangerouslySkipPermissions: true, + permissionMode: "bypassPermissions", + mcpServers: { + calculator: mcpServer, + }, + }, + })) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Verify basic message structure + const assistantMessages = messages.filter((m) => m.type === "assistant"); + expect(assistantMessages.length).toBeGreaterThan(0); + + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage).toBeDefined(); + expect(resultMessage.usage).toBeDefined(); + expect(resultMessage.modelUsage).toBeDefined(); + + // Verify that multiple models are tracked (including hidden ones) + expect(Object.keys(resultMessage.modelUsage).length).toBeGreaterThan(0); + + // Verify server_tool_use is captured if present + if (resultMessage.usage.server_tool_use) { + expect(resultMessage.usage.server_tool_use).toBeDefined(); + expect( + resultMessage.usage.server_tool_use.web_search_requests, + ).toBeDefined(); + expect( + resultMessage.usage.server_tool_use.web_fetch_requests, + ).toBeDefined(); + } + }); + + test("tracks token usage including cache tokens", async () => { + const messages: any[] = []; + + // Use a longer prompt that might benefit from caching + const longPrompt = ` + Here is some context that might be cached: + ${"Lorem ipsum dolor sit amet. ".repeat(100)} + + Now answer this simple question: What is 2+2? + `.trim(); + + for await (const message of wrappedSDK.query({ + prompt: longPrompt, + options: { + model: "haiku", + maxTurns: 1, + }, + })) { + messages.push(message); + } + + const assistantMessage = messages.find((m) => m.type === "assistant"); + expect(assistantMessage?.message?.usage).toBeDefined(); + expect(assistantMessage.message.usage.input_tokens).toBeGreaterThan(0); + expect(assistantMessage.message.usage.output_tokens).toBeGreaterThan(0); + + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage?.usage).toBeDefined(); + }); + + test("handles streaming messages correctly", async () => { + const messages: any[] = []; + let lastMessageId: string | undefined; + let _messageIdChanges = 0; + + for await (const message of wrappedSDK.query({ + prompt: "Write a haiku about programming.", + options: { + allowDangerouslySkipPermissions: true, + permissionMode: "bypassPermissions", + includePartialMessages: true, + model: "haiku", + maxTurns: 1, + }, + })) { + messages.push(message); + + if (message.type === "assistant" && message.message?.id) { + if (lastMessageId && lastMessageId !== message.message.id) { + _messageIdChanges += 1; + } + lastMessageId = message.message.id; + } + } + + expect(messages.length).toBeGreaterThan(0); + expect(messages.some((m) => m.type === "assistant")).toBe(true); + expect(messages.some((m) => m.type === "result")).toBe(true); + }); + + test("custom configuration is applied", async () => { + const customWrappedSDK = wrapClaudeAgentSDK(claudeSDK, { + project_name: "custom-project", + metadata: { test: "custom-metadata" }, + tags: ["custom", "test"], + }); + + const messages: any[] = []; + + for await (const message of customWrappedSDK.query({ + prompt: "Say hello.", + options: { + model: "haiku", + maxTurns: 1, + }, + })) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + }); + + test("tool with error handling", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.mcp_tool_error", + }) as typeof claudeSDK; + + const errorTool = tracedSDK.tool( + "error-tool", + "A tool that always errors", + {}, + async () => ({ + content: [{ type: "text", text: "Error occurred" }], + isError: true, + }), + ); + + const messages = await consumeQuery( + tracedSDK.query({ + prompt: "Try to use the error-tool from the error-server.", + options: { + model: "haiku", + maxTurns: 5, + mcpServers: { + errorTool: tracedSDK.createSdkMcpServer({ + name: "error-server", + tools: [errorTool], + }), + }, + canUseTool: allowAllTools, + }, + }), + ); + + expect(messages.length).toBeGreaterThan(0); + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage).toBeDefined(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const errorToolRuns = Object.values(tree.data).filter( + (run) => run.name.includes("error-tool") && run.run_type === "tool", + ); + expect(errorToolRuns.length).toBeGreaterThanOrEqual(1); + expect(errorToolRuns.some((run) => run.error != null)).toBe(true); + }); + + test("query with tool usage traces correctly", async () => { + // This test verifies that streaming messages are properly grouped by ID + // and that the final token counts are used (not the initial streaming count of 1) + const calculator = wrappedSDK.tool( + "calculator", + "Performs basic arithmetic operations", + { + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number(), + b: z.number(), + }, + async (args: any) => { + let result: number; + switch (args.operation) { + case "add": + result = args.a + args.b; + break; + case "subtract": + result = args.a - args.b; + break; + case "multiply": + result = args.a * args.b; + break; + case "divide": + result = args.a / args.b; + break; + default: + throw new Error("Unknown operation"); + } + return { + content: [{ type: "text", text: `Result: ${result}` }], + }; + }, + ); + + const messages: any[] = []; + const messageIdToUpdates: Map = new Map(); + + for await (const message of wrappedSDK.query({ + prompt: + "IMPORTANT: You MUST use the calculator tool to compute 42 + 17. Do not calculate yourself. Call the tool with operation='add', a=42, b=17, then report the result you get back.", + options: { + model: "haiku", + maxTurns: 3, + tools: [calculator] as any, + }, + })) { + // Track streaming updates per message ID + if (message.type === "assistant") { + const msg = message as any; + const id = msg.message?.id; + if (id) { + const count = messageIdToUpdates.get(id) || 0; + messageIdToUpdates.set(id, count + 1); + + console.log("Assistant streaming update:", { + id, + updateNumber: count + 1, + model: msg.message?.model, + outputTokens: msg.message?.usage?.output_tokens, + stopReason: msg.message?.stop_reason, + }); + } + } + + if (message.type === "result") { + console.log("Result:", { + num_turns: message.num_turns, + totalOutputTokens: message.usage?.output_tokens, + }); + } + + messages.push(message); + } + + console.log("\nMessage ID update counts:"); + for (const [id, count] of messageIdToUpdates) { + console.log(` ${id}: ${count} updates`); + } + + // Get unique message IDs (should correspond to LLM spans created) + const uniqueMessageIds = [...messageIdToUpdates.keys()]; + console.log(`\nUnique assistant message IDs: ${uniqueMessageIds.length}`); + + expect(messages.length).toBeGreaterThan(0); + + const resultMessage = messages.find((m) => m.type === "result"); + expect(resultMessage).toBeDefined(); + expect(resultMessage.usage.output_tokens).toBeGreaterThan(1); // Should be more than the initial streaming count of 1 + }); + + test("tool failure creates errored tool trace", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.tool_failure", + }) as typeof claudeSDK; + + const messages = await consumeQuery( + tracedSDK.query({ + prompt: + "Run this exact bash command: cat /tmp/__langsmith_test_nonexistent.txt", + options: { + model: "haiku", + allowedTools: ["Bash"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 2, + }, + }), + ); + + const toolResultBlocks = messages.flatMap((message) => + message.type === "user" && Array.isArray(message.message?.content) + ? message.message.content.filter( + (block: any) => block.type === "tool_result", + ) + : [], + ); + expect(toolResultBlocks.length).toBeGreaterThanOrEqual(1); + expect(toolResultBlocks.some((block: any) => block.is_error === true)).toBe( + true, + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const bashRuns = Object.values(tree.data).filter( + (run) => run.name === "Bash" && run.run_type === "tool", + ); + expect(bashRuns.length).toBeGreaterThanOrEqual(1); + expect(bashRuns.some((run) => run.error != null)).toBe(true); + }); + + test("subagent trace includes transcript-reconciled LLM turns and tools", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.subagent", + }) as typeof claudeSDK; + + await consumeQuery( + tracedSDK.query({ + prompt: "Call foo.", + options: { + model: "haiku", + systemPrompt: "You must always call the foo subagent.", + allowedTools: ["Agent"], + agents: { + foo: { + description: "Does foo things.", + prompt: + "You must first call the Bash tool with command 'echo hello', then call the Bash tool with command 'echo world', and then respond with exactly: 'done'", + model: "haiku", + tools: ["Bash"], + }, + }, + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 5, + }, + }), + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const fooEntry = Object.entries(tree.data).find( + ([, run]) => run.name === "foo" && run.run_type === "chain", + ); + if (fooEntry == null) { + throw new Error("Expected foo subagent chain run"); + } + const [fooKey] = fooEntry; + const parentToolKey = tree.edges.find(([, child]) => child === fooKey)?.[0]; + if (parentToolKey == null) { + throw new Error("Expected foo subagent to be nested under a tool run"); + } + expect(tree.data[parentToolKey]).toMatchObject({ + run_type: "tool", + name: expect.stringMatching(/^(Agent|Task)$/), + }); + + const bashChildren = tree.edges.filter(([parent, child]) => { + const childRun = tree.data[child]; + return ( + parent === fooKey && + childRun?.name === "Bash" && + childRun.run_type === "tool" + ); + }); + expect(bashChildren.length).toBeGreaterThanOrEqual(2); + + const subagentLlmChildren = tree.edges.filter(([parent, child]) => { + const childRun = tree.data[child]; + return ( + parent === fooKey && + childRun?.name === "claude.assistant.turn" && + childRun.run_type === "llm" + ); + }); + expect(subagentLlmChildren.length).toBeGreaterThanOrEqual(2); + + for (const [, child] of subagentLlmChildren) { + expect(tree.data[child].extra?.metadata?.usage_metadata).toBeDefined(); + } + }); + + test("resuming a session does not duplicate old LLM runs", async () => { + const firstMessages = await consumeQuery( + wrappedSDK.query({ + prompt: "Say hello.", + options: { + model: "haiku", + systemPrompt: "Answer concisely.", + maxTurns: 1, + }, + }), + ); + const sessionId = firstMessages.find((message) => message.session_id) + ?.session_id; + expect(sessionId).toBeDefined(); + + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.continue_session", + }) as typeof claudeSDK; + + await consumeQuery( + tracedSDK.query({ + prompt: "Say goodbye.", + options: { + model: "haiku", + systemPrompt: "Answer concisely.", + resume: sessionId, + maxTurns: 1, + }, + }), + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const llmRuns = Object.values(tree.data).filter( + (run) => run.run_type === "llm", + ); + expect(llmRuns.length).toBe(1); + }); + + test("custom MCP tool denied by default permissions is traced and closed", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.custom_tool_denied", + }) as typeof claudeSDK; + + const getWeather = tracedSDK.tool( + "get_weather", + "Gets the current weather for a given city.", + { city: z.string() }, + async (args: any) => ({ + content: [{ type: "text", text: `Foggy in ${args.city}` }], + }), + ); + + const server = tracedSDK.createSdkMcpServer({ + name: "weather", + tools: [getWeather], + }); + + const messages = await consumeQuery( + tracedSDK.query({ + prompt: "What's the weather in San Francisco?", + options: { + model: "haiku", + systemPrompt: WEATHER_PROMPT, + mcpServers: { weather: server }, + maxTurns: 3, + }, + }), + ); + + expect( + toolUseNames(messages).some((name) => name.includes("get_weather")), + ).toBe(true); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const weatherRuns = Object.values(tree.data).filter( + (run) => run.name.includes("get_weather") && run.run_type === "tool", + ); + expect(weatherRuns.length).toBeGreaterThanOrEqual(1); + expect( + weatherRuns.every((run) => run.end_time != null || run.error != null), + ).toBe(true); + }); + + test("custom MCP tool granted by canUseTool returns result and is traced", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.custom_tool_granted", + }) as typeof claudeSDK; + + const getWeather = tracedSDK.tool( + "get_weather", + "Gets the current weather for a given city.", + { city: z.string() }, + async (args: any) => ({ + content: [{ type: "text", text: `Foggy in ${args.city}` }], + }), + ); + + const server = tracedSDK.createSdkMcpServer({ + name: "weather", + tools: [getWeather], + }); + + const messages = await consumeQuery( + tracedSDK.query({ + prompt: "What's the weather in San Francisco?", + options: { + model: "haiku", + systemPrompt: WEATHER_PROMPT, + mcpServers: { weather: server }, + canUseTool: allowAllTools, + maxTurns: 3, + }, + }), + ); + + expect( + toolUseNames(messages).some((name) => name.includes("get_weather")), + ).toBe(true); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const weatherRuns = Object.values(tree.data).filter( + (run) => run.name.includes("get_weather") && run.run_type === "tool", + ); + expect(weatherRuns.length).toBeGreaterThanOrEqual(1); + expect(weatherRuns.some((run) => run.outputs != null)).toBe(true); + expect( + [ + ...toolResultContents(messages), + ...weatherRuns.map((run) => stringifyToolResultContent(run.outputs)), + ].some((result) => result.includes("Foggy")), + ).toBe(true); + }); + + test("traceable called from MCP tool handler nests under MCP tool run", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.custom_tool_nested_traceable", + }) as typeof claudeSDK; + + const innerLookup = traceable( + async (city: string) => `nested lookup for ${city}`, + { name: "mcp_nested_lookup", client, tracingEnabled: true }, + ); + + const getWeather = tracedSDK.tool( + "get_weather", + "Gets the current weather for a given city.", + { city: z.string() }, + async (args: any) => { + const lookup = await innerLookup(args.city); + return { + content: [{ type: "text", text: `Foggy in ${args.city}; ${lookup}` }], + }; + }, + ); + + const server = tracedSDK.createSdkMcpServer({ + name: "weather", + tools: [getWeather], + }); + + await consumeQuery( + tracedSDK.query({ + prompt: "What's the weather in San Francisco?", + options: { + model: "haiku", + systemPrompt: WEATHER_PROMPT, + mcpServers: { weather: server }, + canUseTool: allowAllTools, + maxTurns: 3, + }, + }), + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const weatherEntry = Object.entries(tree.data).find( + ([, run]) => run.name.includes("get_weather") && run.run_type === "tool", + ); + const nestedEntry = Object.entries(tree.data).find( + ([, run]) => run.name === "mcp_nested_lookup", + ); + + if (weatherEntry == null) { + throw new Error("Expected get_weather tool run"); + } + if (nestedEntry == null) { + throw new Error("Expected nested traceable run"); + } + + expect(tree.edges).toContainEqual([weatherEntry[0], nestedEntry[0]]); + }); + + test("concurrent SDK queries keep trace state isolated", async () => { + const { client, callSpy } = mockClient(); + const tracedSDK = wrapClaudeAgentSDK(claudeSDK, { + client, + tracingEnabled: true, + name: "test.concurrent_three_clients", + }) as typeof claudeSDK; + + const runOne = async (label: string) => { + const messages = await consumeQuery( + tracedSDK.query({ + prompt: `Run the exact command: echo ${label}`, + options: { + model: "haiku", + systemPrompt: `You must call the Bash tool exactly once with command 'echo ${label}', then stop.`, + allowedTools: ["Bash"], + permissionMode: "bypassPermissions", + allowDangerouslySkipPermissions: true, + maxTurns: 2, + }, + }), + ); + return { + label, + messages, + sessionIds: new Set( + messages.map((message) => message.session_id).filter(Boolean), + ), + }; + }; + + const results = await Promise.all([ + runOne("alpha"), + runOne("beta"), + runOne("gamma"), + ]); + + for (const result of results) { + expect(toolUseNames(result.messages)).toContain("Bash"); + expect( + toolResultContents(result.messages).some((output) => + output.includes(result.label), + ), + ).toBe(true); + expect(result.sessionIds.size).toBeGreaterThanOrEqual(1); + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const bashParentIds = new Set( + tree.edges + .filter(([, child]) => { + const childRun = tree.data[child]; + return childRun?.name === "Bash" && childRun.run_type === "tool"; + }) + .map(([parent]) => parent), + ); + expect(bashParentIds.size).toBe(3); + }); + + test("wrapping query preserves extra methods from generator", async () => { + const query = wrappedSDK.query({ + prompt: "Write a haiku about programming.", + options: { + model: "haiku", + maxTurns: 1, + }, + }); + + expect(query.supportedModels).toBeDefined(); + expect(query.supportedCommands).toBeDefined(); + + await expect(() => query.supportedModels()).not.toThrow(); + await expect(() => query.supportedCommands()).not.toThrow(); + }); + + test("streaming input", async () => { + const query = wrappedSDK.query({ + prompt: (async function* () { + yield { + type: "user" as const, + message: { role: "user" as const, content: "Hello" }, + } as unknown as claudeSDK.SDKUserMessage; + + yield { + type: "user" as const, + message: { role: "user" as const, content: "How are you?" }, + } as unknown as claudeSDK.SDKUserMessage; + })(), + options: { + model: "haiku", + maxTurns: 1, + }, + }); + + const messages: any[] = []; + for await (const message of query) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/tests/claude_agent_sdk.test.ts b/src/lib/tests/claude_agent_sdk.test.ts new file mode 100644 index 0000000..2b03d98 --- /dev/null +++ b/src/lib/tests/claude_agent_sdk.test.ts @@ -0,0 +1,3035 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, test, expect, jest } from "@jest/globals"; +import { + convertFromAnthropicMessage, + mergeMessagesById, +} from "../experimental/anthropic/messages.js"; +import { wrapClaudeAgentSDK } from "../experimental/anthropic/index.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; + +// Mock Claude Agent SDK types and functions +type MockSDKMessage = { + type: string; + message?: { + id?: string; + role?: string; + content?: unknown; + model?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + }; + usage?: { + input_tokens?: number; + output_tokens?: number; + cache_read_input_tokens?: number; + cache_creation_input_tokens?: number; + }; + num_turns?: number; + session_id?: string; +}; + +type MockQueryParams = { + prompt?: string | AsyncIterable; + options?: Record; +}; + +// Mock Claude Agent SDK +const createMockSDK = () => { + const inputSpy = jest.fn(); + + const mockQuery = async function* ( + params: MockQueryParams, + ): AsyncGenerator { + // Simulate system message + const prompt = + typeof params.prompt === "string" ? [params.prompt] : params.prompt ?? []; + + for await (const message of prompt) { + inputSpy(message, { createdAt: Date.now() }); + + yield { + type: "system", + session_id: "session_456", + }; + + // Simulate assistant message with streaming + yield { + type: "assistant", + message: { + id: `msg_123_${crypto.randomUUID()}`, + role: "assistant", + content: "Hello! How can I help you?", + model: "claude-3-5-sonnet-20241022", + usage: { + input_tokens: 10, + output_tokens: 8, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }; + + // Simulate result message + yield { + type: "result", + usage: { + input_tokens: 10, + output_tokens: 8, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + num_turns: 1, + session_id: "session_456", + }; + } + }; + + const mockTool = ( + name: string, + description: string, + inputSchema: unknown, + handler: ( + args: T, + extra: unknown, + ) => Promise<{ + content: Array; + isError?: boolean; + }>, + ) => { + return { + name, + description, + inputSchema, + handler, + }; + }; + + const mockCreateSdkMcpServer = () => { + return { + listen: () => Promise.resolve(), + }; + }; + + return { + query: mockQuery, + tool: mockTool, + createSdkMcpServer: mockCreateSdkMcpServer, + + spy: { input: inputSpy }, + }; +}; + +describe("Claude Agent SDK message utilities", () => { + test("merges thinking and text chunks with the same assistant message id", () => { + const thinking = convertFromAnthropicMessage({ + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + model: "claude-haiku-4-5-20251001", + content: [ + { + type: "thinking", + thinking: "I should answer exactly.", + signature: "sig_1", + }, + ], + usage: { input_tokens: 10, output_tokens: 7 }, + }, + } as any); + + const text = convertFromAnthropicMessage({ + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + model: "claude-haiku-4-5-20251001", + content: [{ type: "text", text: "Hello from LangSmith!" }], + usage: { input_tokens: 10, output_tokens: 7 }, + }, + } as any); + + expect(mergeMessagesById(thinking, text)).toEqual([ + { + id: "msg_1", + role: "assistant", + model: "claude-haiku-4-5-20251001", + content: [ + { + type: "thinking", + thinking: "I should answer exactly.", + signature: "sig_1", + }, + { type: "text", text: "Hello from LangSmith!" }, + ], + usage: { input_tokens: 10, output_tokens: 7 }, + }, + ]); + }); + + test("merges text and tool_use chunks with the same assistant message id", () => { + const text = convertFromAnthropicMessage({ + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_2", + role: "assistant", + content: [{ type: "text", text: "I will inspect the file." }], + }, + } as any); + + const toolUse = convertFromAnthropicMessage({ + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_2", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_1", + name: "Read", + input: { file_path: "/tmp/example.txt" }, + }, + ], + }, + } as any); + + expect(mergeMessagesById(text, toolUse)).toEqual([ + { + id: "msg_2", + role: "assistant", + content: [ + { type: "text", text: "I will inspect the file." }, + { + type: "tool_use", + id: "toolu_1", + name: "Read", + input: { file_path: "/tmp/example.txt" }, + }, + ], + }, + ]); + }); + + test("deduplicates repeated chunks for the same assistant message id", () => { + const thinking = { + id: "msg_3", + role: "assistant", + content: [ + { + type: "thinking", + thinking: "Same chunk.", + signature: "sig_duplicate", + }, + ], + }; + + expect(mergeMessagesById([thinking], [thinking])).toEqual([thinking]); + }); + + test("does not merge distinct assistant message ids", () => { + const first = [ + { + id: "msg_4", + role: "assistant", + content: [{ type: "text", text: "First" }], + }, + ]; + const second = [ + { + id: "msg_5", + role: "assistant", + content: [{ type: "text", text: "Second" }], + }, + ]; + + expect(mergeMessagesById(first, second)).toEqual([...first, ...second]); + }); + + test("does not merge tool result messages even when ids are absent", () => { + const first = [{ role: "tool", content: "first", tool_call_id: "toolu_1" }]; + const second = [ + { role: "tool", content: "second", tool_call_id: "toolu_2" }, + ]; + + expect(mergeMessagesById(first, second)).toEqual([...first, ...second]); + }); +}); + +describe("wrapClaudeAgentSDK", () => { + test("wraps query function and traces agent interactions", async () => { + const mockSDK = createMockSDK(); + const wrapped = wrapClaudeAgentSDK(mockSDK); + + const messages: MockSDKMessage[] = []; + for await (const message of wrapped.query({ + prompt: "Hello, Claude!", + options: { model: "claude-3-5-sonnet-20241022" }, + })) { + messages.push(message); + } + + expect(messages).toMatchObject([ + { type: "system", session_id: "session_456" }, + { + type: "assistant", + message: { content: "Hello! How can I help you?" }, + }, + { type: "result" }, + ]); + }); + + test("wraps tool handler with tracing", async () => { + const mockSDK = createMockSDK(); + const wrapped = wrapClaudeAgentSDK(mockSDK); + + const calculator = wrapped.tool( + "calculator", + "Performs basic math operations", + { type: "object", properties: { expression: { type: "string" } } }, + async (args: { expression: string }) => { + return { + content: [{ type: "text", text: `Result: ${eval(args.expression)}` }], + }; + }, + ); + + expect(calculator.name).toBe("calculator"); + expect(calculator.description).toBe("Performs basic math operations"); + + const result = await calculator.handler({ expression: "2 + 2" }, {}); + expect(result.content).toBeDefined(); + expect(result.content.length).toBeGreaterThan(0); + }); + + test("handles multiple message groups with different IDs", async () => { + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + // First message group + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "First response", + usage: { input_tokens: 5, output_tokens: 3 }, + }, + }; + + // Second message group + yield { + type: "assistant", + message: { + id: "msg_2", + role: "assistant", + content: "Second response", + usage: { input_tokens: 3, output_tokens: 4 }, + }, + }; + + // Result + yield { + type: "result", + usage: { input_tokens: 8, output_tokens: 7 }, + num_turns: 2, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK); + const messages: MockSDKMessage[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + expect(messages.length).toBe(3); + expect(messages[0].message?.id).toBe("msg_1"); + expect(messages[1].message?.id).toBe("msg_2"); + expect(messages[2].type).toBe("result"); + }); + + test("extracts and tracks token usage correctly", async () => { + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Response with cache", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + }, + }; + + yield { + type: "result", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 20, + cache_creation_input_tokens: 10, + }, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK); + const messages: MockSDKMessage[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + const assistantMessage = messages[0]; + expect(assistantMessage.message?.usage?.input_tokens).toBe(100); + expect(assistantMessage.message?.usage?.output_tokens).toBe(50); + expect(assistantMessage.message?.usage?.cache_read_input_tokens).toBe(20); + expect(assistantMessage.message?.usage?.cache_creation_input_tokens).toBe( + 10, + ); + }); + + test("passes through createSdkMcpServer unchanged", () => { + const mockSDK = createMockSDK(); + const wrapped = wrapClaudeAgentSDK(mockSDK); + + expect(wrapped.createSdkMcpServer).toBeDefined(); + expect(typeof wrapped.createSdkMcpServer).toBe("function"); + }); + + test("accepts custom configuration", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = createMockSDK(); + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + project_name: "test-project", + metadata: { custom: "metadata" }, + tags: ["test-tag"], + }); + + const messages: MockSDKMessage[] = []; + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + data: { + "claude.conversation:0": { + extra: { + metadata: expect.objectContaining({ + ls_integration: "claude-agent-sdk-js", + custom: "metadata", + }), + }, + }, + }, + }); + }); + + test("handles async iterable prompt", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = createMockSDK(); + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + async function* promptStream(): AsyncIterable { + yield { type: "user", message: { role: "user", content: "Hello" } }; + + await new Promise((resolve) => setTimeout(resolve, 500)); + + yield { + type: "user", + message: { role: "user", content: "How are you?" }, + }; + } + + const messages: MockSDKMessage[] = []; + for await (const message of wrapped.query({ prompt: promptStream() })) { + messages.push(message); + } + + expect(mockSDK.spy.input).toHaveBeenCalledTimes(2); + expect(mockSDK.spy.input).toHaveBeenCalledWith( + { type: "user", message: { role: "user", content: "Hello" } }, + { createdAt: expect.any(Number) }, + ); + expect(mockSDK.spy.input).toHaveBeenCalledWith( + { type: "user", message: { role: "user", content: "How are you?" } }, + { createdAt: expect.any(Number) }, + ); + + const extractDuration = (call: unknown[]) => { + const [, { createdAt }] = call as [unknown, { createdAt: number }]; + return createdAt; + }; + + expect( + extractDuration(mockSDK.spy.input.mock.calls[1]) - + extractDuration(mockSDK.spy.input.mock.calls[0]), + ).toBeGreaterThan(250); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "claude.assistant.turn:0", + "claude.assistant.turn:1", + "claude.conversation:2", + ], + edges: [ + ["claude.conversation:2", "claude.assistant.turn:0"], + ["claude.conversation:2", "claude.assistant.turn:1"], + ], + data: { + "claude.conversation:2": { + run_type: "chain", + inputs: { + messages: [ + { content: "Hello", role: "user" }, + { content: "How are you?", role: "user" }, + ], + }, + outputs: { + output: { + messages: [ + { role: "assistant", content: "Hello! How can I help you?" }, + { role: "assistant", content: "Hello! How can I help you?" }, + ], + }, + }, + }, + "claude.assistant.turn:0": { + run_type: "llm", + inputs: { messages: [{ content: "Hello", role: "user" }] }, + outputs: { + output: { + messages: [ + { role: "assistant", content: "Hello! How can I help you?" }, + ], + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { + messages: [ + { content: "Hello", role: "user" }, + { role: "assistant", content: "Hello! How can I help you?" }, + { role: "user", content: "How are you?" }, + ], + }, + outputs: { + output: { + messages: [ + { role: "assistant", content: "Hello! How can I help you?" }, + ], + }, + }, + }, + }, + }); + }); + + test("adjusts output tokens correctly for final result", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system" }; + + // First message + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Part 1", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }; + + // Second message with same ID (streaming) + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Part 1 complete", + usage: { input_tokens: 10, output_tokens: 8 }, + }, + }; + + // Result with total tokens + yield { + type: "result", + usage: { input_tokens: 10, output_tokens: 15 }, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: MockSDKMessage[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + // The last assistant message should have adjusted tokens + expect(messages.length).toBe(4); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["claude.conversation:0", "claude.assistant.turn:1"], + edges: [["claude.conversation:0", "claude.assistant.turn:1"]], + data: { + "claude.conversation:0": { + run_type: "chain", + extra: { + metadata: { + ls_aggregated_usage: { + input_tokens: 10, + output_tokens: 15, + total_tokens: 25, + }, + }, + }, + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { + messages: [{ role: "assistant", content: "Part 1 complete" }], + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + extra: { + metadata: { + usage_metadata: { + input_tokens: 10, + output_tokens: 8, + total_tokens: 18, + }, + }, + }, + inputs: { + messages: [{ role: "user", content: "Test" }], + }, + outputs: { + output: { + messages: [{ role: "assistant", content: "Part 1 complete" }], + }, + }, + }, + }, + }); + }); + + test("handles UserMessage in conversation", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { + type: "system", + session_id: "session_456", + }; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "First response", + usage: { input_tokens: 5, output_tokens: 3 }, + }, + }; + + // User message in the middle of conversation + yield { + type: "user", + message: { content: [{ type: "text", text: "Follow up question" }] }, + }; + + yield { + type: "assistant", + message: { + id: "msg_2", + role: "assistant", + content: "Second response", + usage: { input_tokens: 8, output_tokens: 5 }, + }, + }; + + yield { + type: "result", + usage: { input_tokens: 13, output_tokens: 8 }, + num_turns: 2, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: MockSDKMessage[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + expect(messages).toMatchObject([ + { type: "system", session_id: "session_456" }, + { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "First response", + usage: { input_tokens: 5, output_tokens: 3 }, + }, + }, + { + type: "user", + message: { content: [{ type: "text", text: "Follow up question" }] }, + }, + { + type: "assistant", + message: { + id: "msg_2", + role: "assistant", + content: "Second response", + usage: { input_tokens: 8, output_tokens: 5 }, + }, + }, + { + type: "result", + usage: { input_tokens: 13, output_tokens: 8 }, + num_turns: 2, + }, + ]); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "claude.conversation:0", + "claude.assistant.turn:1", + "claude.assistant.turn:2", + ], + edges: [ + ["claude.conversation:0", "claude.assistant.turn:1"], + ["claude.conversation:0", "claude.assistant.turn:2"], + ], + data: { + "claude.conversation:0": { + run_type: "chain", + inputs: { messages: [{ content: "Test", role: "user" }] }, + extra: { + metadata: { + ls_aggregated_usage: { + input_tokens: 13, + output_tokens: 8, + total_tokens: 21, + }, + num_turns: 2, + }, + }, + outputs: { + output: { + messages: [ + { role: "assistant", content: "First response" }, + { content: [{ type: "text", text: "Follow up question" }] }, + { role: "assistant", content: "Second response" }, + ], + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { + messages: [{ role: "assistant", content: "First response" }], + }, + }, + }, + "claude.assistant.turn:2": { + run_type: "llm", + inputs: { + messages: [ + { content: "Test", role: "user" }, + { content: "First response", role: "assistant" }, + { + content: [{ type: "text", text: "Follow up question" }], + role: "user", + }, + ], + }, + outputs: { + output: { + messages: [{ role: "assistant", content: "Second response" }], + }, + }, + }, + }, + }); + }); + + test("extracts metadata from ResultMessage", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system", session_id: "session_abc123" }; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Response", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }; + + yield { + type: "result", + usage: { input_tokens: 10, output_tokens: 5 }, + num_turns: 3, + session_id: "session_abc123", + duration_ms: 1500, + duration_api_ms: 1200, + is_error: false, + stop_reason: "end_turn", + total_cost_usd: 0.0015, + } as any; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: any[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + expect(messages).toMatchObject([ + { type: "system", session_id: "session_abc123" }, + { + type: "assistant", + message: { content: "Response" }, + }, + { + type: "result", + num_turns: 3, + session_id: "session_abc123", + duration_ms: 1500, + total_cost_usd: 0.0015, + }, + ]); + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: ["claude.conversation:0", "claude.assistant.turn:1"], + edges: [["claude.conversation:0", "claude.assistant.turn:1"]], + data: { + "claude.conversation:0": { + run_type: "chain", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { messages: [{ role: "assistant", content: "Response" }] }, + }, + extra: { + metadata: { + num_turns: 3, + is_error: false, + session_id: "session_abc123", + thread_id: "session_abc123", + + duration_ms: 1500, + duration_api_ms: 1200, + + ls_aggregated_usage: { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + total_cost: 0.0015, + }, + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { + messages: [{ role: "assistant", content: "Response" }], + }, + }, + }, + }, + }); + }); + + test("extracts model from AssistantMessage", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system", session_id: "session_abc123" }; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Response", + model: "claude-opus-4-20250514", + usage: { input_tokens: 10, output_tokens: 5 }, + }, + }; + + yield { + type: "result", + usage: { input_tokens: 10, output_tokens: 5 }, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: any[] = []; + + for await (const message of wrapped.query({ + prompt: "Test", + options: { model: "claude-3-5-sonnet-20241022" }, + })) { + messages.push(message); + } + + // Model from message should be preserved + expect(messages).toMatchObject([ + { type: "system", session_id: "session_abc123" }, + { + type: "assistant", + message: { content: "Response", model: "claude-opus-4-20250514" }, + }, + { type: "result", usage: { input_tokens: 10, output_tokens: 5 } }, + ]); + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: ["claude.conversation:0", "claude.assistant.turn:1"], + edges: [["claude.conversation:0", "claude.assistant.turn:1"]], + data: { + "claude.conversation:0": { + run_type: "chain", + inputs: { + messages: [{ content: "Test", role: "user" }], + options: { model: "claude-3-5-sonnet-20241022" }, + }, + outputs: { + output: { messages: [{ role: "assistant", content: "Response" }] }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { messages: [{ role: "assistant", content: "Response" }] }, + }, + extra: { metadata: { ls_model_name: "claude-opus-4-20250514" } }, + }, + }, + }); + }); + + test("merges thinking and text chunks for the same assistant message", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system", session_id: "session_abc123" }; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + model: "claude-haiku-4-5-20251001", + content: [ + { + type: "thinking", + thinking: "I should say the requested phrase.", + signature: "sig_1", + }, + ], + usage: { input_tokens: 10, output_tokens: 7 }, + }, + } as any; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + model: "claude-haiku-4-5-20251001", + content: [{ type: "text", text: "Hello from LangSmith!" }], + usage: { input_tokens: 10, output_tokens: 7 }, + }, + } as any; + + yield { + type: "result", + usage: { input_tokens: 10, output_tokens: 7 }, + session_id: "session_abc123", + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + for await (const _message of wrapped.query({ prompt: "Test" })) { + // consume stream + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + data: { + "claude.assistant.turn:1": { + outputs: { + output: { + messages: [ + { + role: "assistant", + id: "msg_1", + content: [ + { + type: "thinking", + thinking: "I should say the requested phrase.", + signature: "sig_1", + }, + { type: "text", text: "Hello from LangSmith!" }, + ], + }, + ], + }, + }, + }, + }, + }); + }); + + test("handles Task tool for subagent tracing", async () => { + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + // Main agent spawns a Task (subagent) + yield { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + content: [ + { type: "text", text: "Let me search for that." }, + { + type: "tool_use", + id: "tool_use_1", + name: "Task", + input: { + subagent_type: "code-reviewer", + prompt: "Review this code", + description: "Review code for bugs", + }, + }, + ], + usage: { input_tokens: 20, output_tokens: 15 }, + }, + } as any; + + // Subagent responds (parent_tool_use_id points to the Task tool) + yield { + type: "assistant", + parent_tool_use_id: "tool_use_1", + message: { + id: "msg_2", + role: "assistant", + content: [ + { type: "text", text: "I found some issues." }, + { + type: "tool_use", + id: "tool_use_2", + name: "Read", + input: { file_path: "/src/main.ts" }, + }, + ], + usage: { input_tokens: 10, output_tokens: 8 }, + }, + } as any; + + // Main agent continues after subagent completes + yield { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_3", + role: "assistant", + content: [{ type: "text", text: "The review is complete." }], + usage: { input_tokens: 15, output_tokens: 10 }, + }, + } as any; + + yield { + type: "result", + usage: { input_tokens: 45, output_tokens: 33 }, + num_turns: 3, + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK); + const messages: any[] = []; + + for await (const message of wrapped.query({ prompt: "Review my code" })) { + messages.push(message); + } + + expect(messages.length).toBe(4); + + // First message spawns Task tool + expect(messages[0].type).toBe("assistant"); + expect(messages[0].parent_tool_use_id).toBeNull(); + expect(messages[0].message.content[1].name).toBe("Task"); + + // Second message is from subagent + expect(messages[1].type).toBe("assistant"); + expect(messages[1].parent_tool_use_id).toBe("tool_use_1"); + + // Third message is back to main agent + expect(messages[2].type).toBe("assistant"); + expect(messages[2].parent_tool_use_id).toBeNull(); + + // Result + expect(messages[3].type).toBe("result"); + }); + + test("reconciles hidden subagent transcript turns and usage", async () => { + const { client, callSpy } = mockClient(); + const tmpDir = await mkdtemp(join(tmpdir(), "ls-claude-transcripts-")); + const mainTranscriptPath = join(tmpDir, "main.jsonl"); + const subagentTranscriptPath = join(tmpDir, "subagent.jsonl"); + + await writeFile( + mainTranscriptPath, + `${JSON.stringify({ + type: "assistant", + timestamp: "2026-01-01T00:00:00.000Z", + message: { + id: "msg_parent", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "task_1", + name: "Task", + input: { subagent_type: "reviewer", prompt: "Review code" }, + }, + ], + stop_reason: "tool_use", + usage: { input_tokens: 100, output_tokens: 50 }, + }, + })}\n`, + ); + + await writeFile( + subagentTranscriptPath, + [ + { + type: "user", + timestamp: "2026-01-01T00:00:01.000Z", + message: { role: "user", content: "Review code" }, + }, + { + type: "assistant", + timestamp: "2026-01-01T00:00:02.000Z", + message: { + id: "msg_hidden_1", + role: "assistant", + model: "claude-haiku-4-5", + content: [ + { + type: "tool_use", + id: "read_1", + name: "Read", + input: { file_path: "/src/main.ts" }, + }, + ], + stop_reason: "tool_use", + usage: { + input_tokens: 7, + output_tokens: 2, + cache_read_input_tokens: 3, + }, + }, + }, + { + type: "user", + timestamp: "2026-01-01T00:00:03.000Z", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "read_1", + content: "file contents", + }, + ], + }, + }, + { + type: "assistant", + timestamp: "2026-01-01T00:00:04.000Z", + message: { + id: "msg_hidden_2", + role: "assistant", + model: "claude-haiku-4-5", + content: [{ type: "text", text: "The code looks fine." }], + stop_reason: "end_turn", + usage: { input_tokens: 11, output_tokens: 5 }, + }, + }, + ] + .map((entry) => JSON.stringify(entry)) + .join("\n") + "\n", + ); + + const runHooks = async ( + params: MockQueryParams, + eventName: string, + input: Record, + toolUseId?: string, + ) => { + const matchers = (params.options?.hooks as any)?.[eventName] ?? []; + for (const matcher of matchers) { + for (const hook of matcher.hooks ?? []) { + await hook(input, toolUseId, { + signal: new AbortController().signal, + }); + } + } + }; + + const mockSDK = { + ...createMockSDK(), + query: async function* (params: MockQueryParams) { + yield { type: "system", session_id: "session_abc123" }; + yield { + type: "assistant", + parent_tool_use_id: null, + session_id: "session_abc123", + message: { + id: "msg_parent", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "task_1", + name: "Task", + input: { subagent_type: "reviewer", prompt: "Review code" }, + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + } as any; + + await runHooks( + params, + "PreToolUse", + { + hook_event_name: "PreToolUse", + transcript_path: mainTranscriptPath, + tool_name: "Agent", + tool_input: { subagent_type: "reviewer", prompt: "Review code" }, + }, + "task_1", + ); + await runHooks(params, "SubagentStart", { + hook_event_name: "SubagentStart", + transcript_path: mainTranscriptPath, + agent_id: "agent_1", + agent_type: "reviewer", + }); + await runHooks(params, "SubagentStop", { + hook_event_name: "SubagentStop", + transcript_path: mainTranscriptPath, + agent_id: "agent_1", + agent_type: "reviewer", + agent_transcript_path: subagentTranscriptPath, + }); + + yield { + type: "result", + usage: { input_tokens: 100, output_tokens: 57 }, + session_id: "session_abc123", + }; + }, + }; + + try { + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + for await (const _message of wrapped.query({ + prompt: "Review my code", + options: {}, + })) { + // consume stream + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: [ + "claude.conversation:0", + "Task:1", + "reviewer:2", + "claude.assistant.turn:3", + "Read:4", + "claude.assistant.turn:5", + "claude.assistant.turn:6", + ], + edges: [ + ["claude.conversation:0", "Task:1"], + ["Task:1", "reviewer:2"], + ["claude.conversation:0", "claude.assistant.turn:3"], + ["reviewer:2", "Read:4"], + ["reviewer:2", "claude.assistant.turn:5"], + ["reviewer:2", "claude.assistant.turn:6"], + ], + data: { + "Task:1": { + run_type: "tool", + inputs: { + input: { subagent_type: "reviewer", prompt: "Review code" }, + }, + }, + "reviewer:2": { + run_type: "chain", + inputs: { subagent_type: "reviewer", prompt: "Review code" }, + }, + "claude.assistant.turn:3": { + extra: { + metadata: { + usage_metadata: { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }, + }, + }, + }, + "Read:4": { + run_type: "tool", + inputs: { input: { file_path: "/src/main.ts" } }, + outputs: { content: "file contents" }, + }, + "claude.assistant.turn:5": { + run_type: "llm", + inputs: { messages: [{ content: "Review code", role: "user" }] }, + extra: { + metadata: { + ls_model_name: "claude-haiku-4-5", + usage_metadata: { + input_tokens: 10, + output_tokens: 2, + total_tokens: 12, + input_token_details: { cache_read: 3 }, + }, + }, + }, + }, + "claude.assistant.turn:6": { + run_type: "llm", + outputs: { + output: { + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "The code looks fine." }], + }, + ], + }, + }, + }, + }, + }); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + test("stores aggregate modelUsage as ls_aggregated_usage without parent rollup usage", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system", session_id: "session_abc123" }; + + yield { + type: "assistant", + message: { + id: "msg_1", + role: "assistant", + content: "Response", + model: "claude-sonnet-4-20250514", + usage: { input_tokens: 100, output_tokens: 40 }, + }, + }; + + yield { + type: "result", + usage: { input_tokens: 100, output_tokens: 50 }, + modelUsage: { + "claude-sonnet-4-20250514": { + inputTokens: 80, + outputTokens: 40, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 10, + webSearchRequests: 0, + costUSD: 0.001, + contextWindow: 200000, + }, + "claude-haiku-4-20250514": { + inputTokens: 20, + outputTokens: 10, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.0001, + contextWindow: 200000, + }, + }, + session_id: "session_abc123", + total_cost_usd: 0.0011, + } as any; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: any[] = []; + + for await (const message of wrapped.query({ prompt: "Test" })) { + messages.push(message); + } + + expect(messages).toMatchObject([ + { type: "system", session_id: "session_abc123" }, + { type: "assistant", message: { content: "Response" } }, + { + type: "result", + modelUsage: { + "claude-sonnet-4-20250514": { inputTokens: 80 }, + "claude-haiku-4-20250514": { inputTokens: 20, costUSD: 0.0001 }, + }, + total_cost_usd: 0.0011, + }, + ]); + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect( + res.data["claude.conversation:0"].extra?.metadata?.usage_metadata, + ).toBeUndefined(); + expect(res).toMatchObject({ + nodes: ["claude.conversation:0", "claude.assistant.turn:1"], + edges: [["claude.conversation:0", "claude.assistant.turn:1"]], + data: { + "claude.conversation:0": { + run_type: "chain", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { messages: [{ role: "assistant", content: "Response" }] }, + }, + extra: { + metadata: { + ls_aggregated_usage: { + input_tokens: 120, + output_tokens: 50, + total_tokens: 170, + input_token_details: { cache_read: 10, cache_creation: 10 }, + total_cost: 0.0011, + }, + session_id: "session_abc123", + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { messages: [{ content: "Test", role: "user" }] }, + outputs: { + output: { messages: [{ role: "assistant", content: "Response" }] }, + }, + extra: { + metadata: { + usage_metadata: { + input_token_details: { + ephemeral_5m_input_tokens: 10, + cache_read: 10, + }, + input_tokens: 100, + output_tokens: 40, + total_tokens: 140, + }, + }, + }, + }, + }, + }); + }); + + test("handles nested tools within subagent", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* ( + _params: MockQueryParams, + ): AsyncGenerator { + yield { type: "system", session_id: "session_abc123" }; + // Main agent spawns a Task + yield { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + content: [ + { + type: "tool_use", + id: "task_1", + name: "Task", + input: { subagent_type: "explorer", prompt: "Find files" }, + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } as any; + + // Subagent uses Glob tool + yield { + type: "assistant", + parent_tool_use_id: "task_1", + message: { + id: "msg_2", + role: "assistant", + content: [ + { + type: "tool_use", + id: "glob_1", + name: "Glob", + input: { pattern: "**/*.ts" }, + }, + ], + usage: { input_tokens: 5, output_tokens: 3 }, + }, + } as any; + + // Subagent uses Read tool + yield { + type: "assistant", + parent_tool_use_id: "task_1", + message: { + id: "msg_3", + role: "assistant", + content: [ + { + type: "tool_use", + id: "read_1", + name: "Read", + input: { file_path: "/src/index.ts" }, + }, + ], + usage: { input_tokens: 8, output_tokens: 4 }, + }, + } as any; + + yield { + type: "result", + usage: { input_tokens: 23, output_tokens: 12 }, + session_id: "session_abc123", + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const messages: any[] = []; + + for await (const message of wrapped.query({ + prompt: "Find TypeScript files", + })) { + messages.push(message); + } + + expect(messages).toMatchObject([ + { type: "system", session_id: "session_abc123" }, + { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + content: [ + { + type: "tool_use", + id: "task_1", + name: "Task", + input: { subagent_type: "explorer", prompt: "Find files" }, + }, + ], + }, + }, + { + type: "assistant", + parent_tool_use_id: "task_1", + message: { + role: "assistant", + content: [{ type: "tool_use", name: "Glob" }], + }, + }, + { + type: "assistant", + parent_tool_use_id: "task_1", + message: { + role: "assistant", + content: [{ type: "tool_use", name: "Read" }], + }, + }, + { type: "result", session_id: "session_abc123" }, + ]); + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: [ + "claude.conversation:0", + "Task:1", + "explorer:2", + "claude.assistant.turn:3", + "Glob:4", + "claude.assistant.turn:5", + "Read:6", + "claude.assistant.turn:7", + ], + edges: [ + ["claude.conversation:0", "Task:1"], + ["Task:1", "explorer:2"], + ["claude.conversation:0", "claude.assistant.turn:3"], + ["explorer:2", "Glob:4"], + ["explorer:2", "claude.assistant.turn:5"], + ["explorer:2", "Read:6"], + ["explorer:2", "claude.assistant.turn:7"], + ], + data: { + "Task:1": { + run_type: "tool", + inputs: { + input: { subagent_type: "explorer", prompt: "Find files" }, + }, + error: "Run not completed (conversation ended)", + }, + "explorer:2": { + run_type: "chain", + inputs: { subagent_type: "explorer", prompt: "Find files" }, + error: "Run not completed (conversation ended)", + }, + "Glob:4": { + run_type: "tool", + inputs: { input: { pattern: "**/*.ts" } }, + error: "Run not completed (conversation ended)", + }, + "Read:6": { + run_type: "tool", + inputs: { input: { file_path: "/src/index.ts" } }, + error: "Run not completed (conversation ended)", + }, + "claude.assistant.turn:5": { + run_type: "llm", + outputs: { + output: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "glob_1", + name: "Glob", + input: { pattern: "**/*.ts" }, + }, + ], + }, + ], + }, + }, + }, + "claude.assistant.turn:7": { + run_type: "llm", + outputs: { + output: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "read_1", + name: "Read", + input: { file_path: "/src/index.ts" }, + }, + ], + }, + ], + }, + }, + }, + }, + }); + }); + + test("marks MCP tool results with camelCase isError as errored", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* (_params: MockQueryParams) { + yield { type: "system", session_id: "session_abc123" }; + yield { + type: "assistant", + parent_tool_use_id: null, + message: { + id: "msg_1", + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool_1", + name: "mcp__errorTool__error-tool", + input: {}, + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }, + } as any; + yield { + type: "user", + parent_tool_use_id: null, + session_id: "session_abc123", + tool_use_result: { + content: [{ type: "text", text: "Error occurred" }], + isError: true, + }, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: "Error occurred", + }, + ], + }, + } as any; + yield { + type: "result", + usage: { input_tokens: 10, output_tokens: 5 }, + session_id: "session_abc123", + } as any; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + for await (const _message of wrapped.query({ + prompt: "Use failing MCP tool", + })) { + // consume stream + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const toolRun = Object.values(res.data).find( + (run) => run.name === "mcp__errorTool__error-tool", + ); + expect(toolRun).toMatchObject({ + run_type: "tool", + outputs: { + content: [{ type: "text", text: "Error occurred" }], + isError: true, + }, + error: "Error occurred", + }); + }); + + test("throws error if wrapped again", () => { + const mockSDK = createMockSDK(); + const wrapped = wrapClaudeAgentSDK(mockSDK); + expect(() => wrapClaudeAgentSDK(wrapped)).toThrow( + "This instance of Claude Agent SDK has been already wrapped by `wrapClaudeAgentSDK`.", + ); + }); + + test("subagent tool calling snapshot", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* (_params: MockQueryParams) { + yield { + type: "system", + model: "claude-sonnet-4-6", + claude_code_version: "2.1.50", + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "cb58646c-20e1-4d23-9a5e-4a0424ce9cdc", + }; + + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01R2VbKy9UktL9xxDUybWSdi", + type: "message", + role: "assistant", + content: [ + { + type: "thinking", + thinking: + "The user wants me to tell a joke about the latest date using the joke-agent. The current date is 2026-02-21. Let me launch the joke-agent with this information.", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 16021, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 0, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "38dad3e7-43c3-4d5d-8712-b5e6e891b723", + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01R2VbKy9UktL9xxDUybWSdi", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + name: "Task", + input: { + description: "Tell a joke about today's date", + subagent_type: "joke-agent", + prompt: + "Tell me a funny joke about today's date: February 21, 2026.", + }, + caller: { type: "direct" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 16021, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 0, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "708d075a-c43c-44f2-988d-f8d99361e143", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "text", + text: "Tell me a funny joke about today's date: February 21, 2026.", + }, + ], + }, + parent_tool_use_id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "ece31846-d44f-4fa7-8746-a9f8cda2a538", + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01N5XS2zZLpyHfN7kVtNQ9wL", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01PWkmKr2GaPemM7CLBLaxd2", + name: "Bash", + input: { + command: "date", + description: "Get current date and time", + }, + caller: { type: "direct" }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 416, + cache_read_input_tokens: 4814, + cache_creation: { + ephemeral_5m_input_tokens: 416, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 1, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "fca6d684-6471-4832-bfad-50709af185ab", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01PWkmKr2GaPemM7CLBLaxd2", + type: "tool_result", + content: "Sat Feb 21 20:13:00 CET 2026", + is_error: false, + }, + ], + }, + parent_tool_use_id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "33e50d92-5d44-4b1c-a41d-11e0834261e8", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + type: "tool_result", + content: [ + { + type: "text", + text: 'The date is confirmed: Saturday, February 21, 2026. Here is your timely joke:\n\n---\n\nIt is February 21st, and I have to say -- this date is really underrated. January gets all the "new year, new me" hype, and February 14th gets all the roses... but February 21st? It just shows up, does the work, and asks for nothing in return.\n\nKind of like a senior developer on a Friday afternoon.\n\n---\n\nAnd a bonus one-liner for the date nerds:\n\nWhy did February 21st break up with February 22nd?\n\nBecause it said, "I need space -- and you always come after me."', + }, + { + type: "text", + text: "agentId: a7bf945f9f2425f6f (for resuming to continue this agent's work if needed)\ntotal_tokens: 5501\ntool_uses: 1\nduration_ms: 8420", + }, + ], + }, + ], + }, + parent_tool_use_id: null, + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "25155ced-34a0-44aa-87c9-48660e801147", + tool_use_result: { + status: "completed", + prompt: + "Tell me a funny joke about today's date: February 21, 2026.", + agentId: "a7bf945f9f2425f6f", + content: [ + { + type: "text", + text: 'The date is confirmed: Saturday, February 21, 2026. Here is your timely joke:\n\n---\n\nIt is February 21st, and I have to say -- this date is really underrated. January gets all the "new year, new me" hype, and February 14th gets all the roses... but February 21st? It just shows up, does the work, and asks for nothing in return.\n\nKind of like a senior developer on a Friday afternoon.\n\n---\n\nAnd a bonus one-liner for the date nerds:\n\nWhy did February 21st break up with February 22nd?\n\nBecause it said, "I need space -- and you always come after me."', + }, + ], + totalDurationMs: 8420, + totalTokens: 5501, + totalToolUseCount: 1, + usage: { + input_tokens: 1, + cache_creation_input_tokens: 117, + cache_read_input_tokens: 5230, + output_tokens: 153, + server_tool_use: { + web_search_requests: 0, + web_fetch_requests: 0, + }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 117, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01JRsr4U5QfWhSkNoErECJqQ", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: 'Here\'s what the joke-agent came up with for today\'s date, **February 21, 2026**:\n\n---\n\nFebruary 21st is really underrated. January gets all the "new year, new me" hype, and February 14th gets all the roses... but February 21st? It just shows up, does the work, and asks for nothing in return.\n\n*Kind of like a senior developer on a Friday afternoon.* 😄\n\n---\n\n**Bonus one-liner:**\n\nWhy did February 21st break up with February 22nd?\n\nBecause it said, *"I need space — and you always come after me."* 😂', + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 1, + cache_creation_input_tokens: 390, + cache_read_input_tokens: 16021, + cache_creation: { + ephemeral_5m_input_tokens: 390, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 1, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + uuid: "15f0964b-4f58-43d7-a828-0a282c7d62bb", + }; + yield { + type: "system", + subtype: "task_started", + task_id: "a7bf945f9f2425f6f", + tool_use_id: "toolu_01DEeFdMw6T3H28A3yynMUdd", + description: "Tell a joke about today's date", + task_type: "local_agent", + uuid: "d878891c-1aab-4ce1-8b63-a13a2651ddb6", + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + num_turns: 2, + result: + 'Here\'s what the joke-agent came up with for today\'s date, **February 21, 2026**:\n\n---\n\nFebruary 21st is really underrated. January gets all the "new year, new me" hype, and February 14th gets all the roses... but February 21st? It just shows up, does the work, and asks for nothing in return.\n\n*Kind of like a senior developer on a Friday afternoon.* 😄\n\n---\n\n**Bonus one-liner:**\n\nWhy did February 21st break up with February 22nd?\n\nBecause it said, *"I need space — and you always come after me."* 😂', + stop_reason: null, + session_id: "f8df5951-2251-47b9-a335-6d307c5223d6", + total_cost_usd: 0.041286750000000004, + usage: { + input_tokens: 4, + cache_creation_input_tokens: 390, + cache_read_input_tokens: 32042, + output_tokens: 319, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 390, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + modelUsage: { + "claude-sonnet-4-6": { + inputTokens: 8, + outputTokens: 558, + cacheReadInputTokens: 42086, + cacheCreationInputTokens: 923, + webSearchRequests: 0, + costUSD: 0.040801750000000005, + contextWindow: 200000, + maxOutputTokens: 32000, + }, + "claude-haiku-4-5-20251001": { + inputTokens: 325, + outputTokens: 32, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.00048499999999999997, + contextWindow: 200000, + maxOutputTokens: 32000, + }, + }, + uuid: "bfa7563d-e3c9-4a60-955d-d770ce56d024", + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + const result: unknown[] = []; + for await (const message of wrapped.query({ + prompt: "List available files in the current directory", + options: { + maxTurns: 10, + allowedTools: ["Read", "Grep"], + }, + })) { + result.push(message); + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: [ + "claude.conversation:0", + "claude.assistant.turn:1", + "Task:2", + "joke-agent:3", + "Bash:4", + "claude.assistant.turn:5", + "claude.assistant.turn:6", + ], + edges: [ + ["claude.conversation:0", "claude.assistant.turn:1"], + ["claude.conversation:0", "Task:2"], + ["Task:2", "joke-agent:3"], + ["joke-agent:3", "Bash:4"], + ["joke-agent:3", "claude.assistant.turn:5"], + ["claude.conversation:0", "claude.assistant.turn:6"], + ], + data: { + "Task:2": { + run_type: "tool", + inputs: { + input: { + description: "Tell a joke about today's date", + subagent_type: "joke-agent", + prompt: + "Tell me a funny joke about today's date: February 21, 2026.", + }, + }, + outputs: { + status: "completed", + agentId: "a7bf945f9f2425f6f", + totalTokens: 5501, + totalToolUseCount: 1, + }, + }, + "joke-agent:3": { + run_type: "chain", + inputs: { + description: "Tell a joke about today's date", + subagent_type: "joke-agent", + prompt: + "Tell me a funny joke about today's date: February 21, 2026.", + }, + outputs: { + status: "completed", + agentId: "a7bf945f9f2425f6f", + totalTokens: 5501, + totalToolUseCount: 1, + }, + }, + "Bash:4": { + run_type: "tool", + inputs: { + input: { + command: "date", + description: "Get current date and time", + }, + }, + outputs: { content: "Sat Feb 21 20:13:00 CET 2026" }, + }, + "claude.assistant.turn:5": { + run_type: "llm", + outputs: { + output: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01PWkmKr2GaPemM7CLBLaxd2", + name: "Bash", + input: { + command: "date", + description: "Get current date and time", + }, + }, + ], + }, + ], + }, + }, + }, + }, + }); + }); + + test("tool calling snapshot", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* (_params: MockQueryParams) { + yield { + type: "system", + session_id: "session_123", + model: "claude-sonnet-4-5-20250929", + }; + + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-5-20250929", + id: "msg_01Ln71J2foBvg5RRnPyxwLDr", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "I'll list the files in the current directory for you.", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15157, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 5, + service_tier: "standard", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "session_123", + }; + + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-5-20250929", + id: "msg_01Ln71J2foBvg5RRnPyxwLDr", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_01C6pxkyGufmwfL2fGAot85b", + name: "Bash", + input: { + command: "ls -la", + description: "List files in current directory", + }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 15157, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 88, + service_tier: "standard", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "session_123", + }; + + yield { + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_01C6pxkyGufmwfL2fGAot85b", + type: "tool_result", + content: "total 0", + is_error: false, + }, + ], + }, + parent_tool_use_id: null, + session_id: "session_123", + tool_use_result: { + stdout: "total 0", + stderr: "", + interrupted: false, + isImage: false, + }, + }; + + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-5-20250929", + id: "msg_01XraJX1NbRz2WsTNYqyqAdf", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "Here are the files", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 6, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 17833, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 342, + service_tier: "standard", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "session_123", + }; + + yield { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 9036, + duration_api_ms: 19639, + num_turns: 2, + result: "Here are the files", + session_id: "session_123", + total_cost_usd: 0.0261164, + usage: { + input_tokens: 9, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 32990, + output_tokens: 430, + server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 0, + }, + }, + modelUsage: { + "claude-sonnet-4-5-20250929": { + inputTokens: 12, + outputTokens: 732, + cacheReadInputTokens: 34118, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.021251400000000004, + contextWindow: 200000, + }, + "claude-haiku-4-5-20251001": { + inputTokens: 3890, + outputTokens: 195, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0.0048649999999999995, + contextWindow: 200000, + }, + }, + permission_denials: [], + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + + const result: unknown[] = []; + for await (const message of wrapped.query({ + prompt: "List available files in the current directory", + options: { + maxTurns: 10, + allowedTools: ["Read", "Grep"], + }, + })) { + result.push(message); + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: [ + "claude.conversation:0", + "claude.assistant.turn:1", + "Bash:2", + "claude.assistant.turn:3", + ], + edges: [ + ["claude.conversation:0", "claude.assistant.turn:1"], + ["claude.conversation:0", "Bash:2"], + ["claude.conversation:0", "claude.assistant.turn:3"], + ], + data: { + "claude.conversation:0": { + run_type: "chain", + inputs: { + messages: [ + { + content: "List available files in the current directory", + role: "user", + }, + ], + options: { + allowedTools: ["Read", "Grep"], + maxTurns: 10, + }, + }, + outputs: { + output: { + messages: [ + { + role: "assistant", + content: [ + { + text: "I'll list the files in the current directory for you.", + type: "text", + }, + { + id: "toolu_01C6pxkyGufmwfL2fGAot85b", + input: { + command: "ls -la", + description: "List files in current directory", + }, + name: "Bash", + type: "tool_use", + }, + ], + }, + { + role: "tool", + type: "tool_result", + tool_use_id: "toolu_01C6pxkyGufmwfL2fGAot85b", + content: "total 0", + }, + { + role: "assistant", + content: [ + { + text: "Here are the files", + type: "text", + }, + ], + }, + ], + }, + }, + }, + "claude.assistant.turn:1": { + run_type: "llm", + inputs: { + messages: [ + { + content: "List available files in the current directory", + role: "user", + }, + ], + }, + outputs: { + output: { + messages: [ + { + content: [ + { + text: "I'll list the files in the current directory for you.", + type: "text", + }, + { + id: "toolu_01C6pxkyGufmwfL2fGAot85b", + input: { + command: "ls -la", + description: "List files in current directory", + }, + name: "Bash", + type: "tool_use", + }, + ], + role: "assistant", + }, + ], + }, + }, + }, + "Bash:2": { + run_type: "tool", + inputs: { input: { command: "ls -la" } }, + outputs: { + stdout: "total 0", + }, + }, + "claude.assistant.turn:3": { + run_type: "llm", + inputs: { + messages: [ + { + content: "List available files in the current directory", + role: "user", + }, + { + content: [ + { + text: "I'll list the files in the current directory for you.", + type: "text", + }, + ], + role: "assistant", + }, + { + content: [ + { + id: "toolu_01C6pxkyGufmwfL2fGAot85b", + input: { + command: "ls -la", + description: "List files in current directory", + }, + name: "Bash", + type: "tool_use", + }, + ], + role: "assistant", + }, + { + role: "tool", + tool_use_id: "toolu_01C6pxkyGufmwfL2fGAot85b", + content: "total 0", + }, + ], + }, + outputs: { + output: { + messages: [ + { + content: [{ text: "Here are the files", type: "text" }], + role: "assistant", + }, + ], + }, + }, + }, + }, + }); + }); + + test("subagent calling snapshot", async () => { + const { client, callSpy } = mockClient(); + const mockSDK = { + ...createMockSDK(), + query: async function* (_params: MockQueryParams) { + yield { + type: "system", + subtype: "init", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + + mcp_servers: [], + model: "claude-sonnet-4-6", + permissionMode: "default", + + apiKeySource: "ANTHROPIC_API_KEY", + claude_code_version: "2.1.83", + output_style: "default", + agents: [ + "general-purpose", + "statusline-setup", + "Explore", + "Plan", + "oracle", + ], + skills: [ + "update-config", + "debug", + "simplify", + "batch", + "loop", + "schedule", + "claude-api", + ], + plugins: [], + uuid: "a5ab9b7f-b1bc-4f19-8c5e-93bbbd8f3199", + fast_mode_state: "off", + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01GjybJ6Ro5aJj3Cphhe4TtE", + type: "message", + role: "assistant", + content: [ + { + type: "thinking", + thinking: + "The user wants me to get the current time using the oracle subagent.", + signature: + "Ev8BClkIDBgCKkDq4aCzS3BF3LcasdWEFkXT/Hn7xeTNUwuqLb041SVsc1+ox84BriZwOh2iE61PYhcqGUVlAxCKJ1QiGtp5anXnMhFjbGF1ZGUtc29ubmV0LTQtNhIMHRbxh9G5sofkeErOGgx2ie8GmImq96iLIJciMOw5BpgWL6iTW5/U7mc5NbTEWf9nMxgrZ7/GsJEReLTTdi0xIIZlztM2C72yh7qakipUTVUQiwdgydGWmGp969LJL7Kq5nRCgSu6Hp0fjXGt9cz4Fjlbhlw7RoayXNzVtIRsbxXuOvwwPK6P445XBznqiJuFpQIdjRSZ4ZDVd795kNTw0f43GAE=", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 9488, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_5m_input_tokens: 9488, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 0, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "8d9e4441-c418-41c9-8d1f-7383d1875f89", + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01GjybJ6Ro5aJj3Cphhe4TtE", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + name: "Agent", + input: { + description: "Get current time", + prompt: + "What is the current time? Please run a bash command to get it.", + subagent_type: "oracle", + }, + caller: { + type: "direct", + }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 9488, + cache_read_input_tokens: 0, + cache_creation: { + ephemeral_5m_input_tokens: 9488, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 0, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "3be5b4f6-3cd1-4b61-b820-8fb79d8e813f", + }; + yield { + type: "system", + subtype: "task_started", + task_id: "a81bf415a7d8dc4ea", + tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + description: "Get current time", + task_type: "local_agent", + prompt: + "What is the current time? Please run a bash command to get it.", + uuid: "7cd65e40-eb9d-4007-bb28-153007d6148d", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "text", + text: "What is the current time? Please run a bash command to get it.", + }, + ], + }, + parent_tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "724a1839-593c-433c-b24f-2f0964068a25", + timestamp: "2026-03-25T12:00:17.749Z", + }; + yield { + type: "system", + subtype: "task_progress", + task_id: "a81bf415a7d8dc4ea", + tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + description: "Running Get current date and time", + usage: { + total_tokens: 4282, + tool_uses: 1, + duration_ms: 2508, + }, + last_tool_name: "Bash", + uuid: "9a7900fb-db11-4990-8ebc-30dea5e9262d", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01LKeuToAj9w3P1PEy4TAEjm", + type: "message", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_015z38ZntH1r2AFzYKVEuCnx", + name: "Bash", + input: { + command: "date", + description: "Get current date and time", + }, + caller: { + type: "direct", + }, + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 3, + cache_creation_input_tokens: 923, + cache_read_input_tokens: 3306, + cache_creation: { + ephemeral_5m_input_tokens: 923, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 50, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "c5cd2212-3e5b-4e94-a8e9-d100b68a6dd2", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_015z38ZntH1r2AFzYKVEuCnx", + type: "tool_result", + content: "Wed Mar 25 13:00:20 CET 2026", + is_error: false, + }, + ], + }, + parent_tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "06512cdd-e63e-480a-9410-a2af5b60b322", + timestamp: "2026-03-25T12:00:20.771Z", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "a81bf415a7d8dc4ea", + tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + status: "completed", + output_file: "", + summary: "Get current time", + usage: { + total_tokens: 4387, + tool_uses: 1, + duration_ms: 5325, + }, + uuid: "51b17eb0-f59d-43bf-aa4a-69f6b78aeca1", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + tool_use_id: "toolu_011NwzdcdoWCS2kpb93oXbQm", + type: "tool_result", + content: [ + { + type: "text", + text: "The current time is 13:00:20 CET (Central European Time) on Wednesday, March 25, 2026.", + }, + { + type: "text", + text: "agentId: a81bf415a7d8dc4ea (use SendMessage with to: 'a81bf415a7d8dc4ea' to continue this agent)\ntotal_tokens: 4366\ntool_uses: 1\nduration_ms: 5327", + }, + ], + }, + ], + }, + parent_tool_use_id: null, + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "e1977f73-721b-4019-b4ce-8c355ef5c60d", + timestamp: "2026-03-25T12:00:23.076Z", + tool_use_result: { + status: "completed", + prompt: + "What is the current time? Please run a bash command to get it.", + agentId: "a81bf415a7d8dc4ea", + content: [ + { + type: "text", + text: "The current time is 13:00:20 CET (Central European Time) on Wednesday, March 25, 2026.", + }, + ], + totalDurationMs: 5327, + totalTokens: 4366, + totalToolUseCount: 1, + usage: { + input_tokens: 1, + cache_creation_input_tokens: 1027, + cache_read_input_tokens: 3306, + output_tokens: 32, + server_tool_use: { + web_search_requests: 0, + web_fetch_requests: 0, + }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 1027, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + }, + }; + yield { + type: "assistant", + message: { + model: "claude-sonnet-4-6", + id: "msg_01GLhdrGMmZd2VLeVfKFemtG", + type: "message", + role: "assistant", + content: [ + { + type: "text", + text: "The oracle subagent has returned the current time:\n\n> 🕐 **Current Time:** 1:00:20 PM CET (Central European Time)\n> 📅 **Date:** Wednesday, March 25, 2026", + }, + ], + stop_reason: null, + stop_sequence: null, + usage: { + input_tokens: 1, + cache_creation_input_tokens: 247, + cache_read_input_tokens: 9488, + cache_creation: { + ephemeral_5m_input_tokens: 247, + ephemeral_1h_input_tokens: 0, + }, + output_tokens: 1, + service_tier: "standard", + inference_geo: "global", + }, + context_management: null, + }, + parent_tool_use_id: null, + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + uuid: "f797c716-5146-4844-b3c6-dadf6c437a74", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 9711, + duration_api_ms: 9193, + num_turns: 2, + result: + "The oracle subagent has returned the current time:\n\n> 🕐 **Current Time:** 1:00:20 PM CET (Central European Time)\n> 📅 **Date:** Wednesday, March 25, 2026", + stop_reason: "end_turn", + session_id: "0aaeb22f-9a28-44c1-95f0-32cbfe025a92", + total_cost_usd: 0.053157750000000004, + usage: { + input_tokens: 4, + cache_creation_input_tokens: 9735, + cache_read_input_tokens: 9488, + output_tokens: 193, + server_tool_use: { + web_search_requests: 0, + web_fetch_requests: 0, + }, + service_tier: "standard", + cache_creation: { + ephemeral_1h_input_tokens: 0, + ephemeral_5m_input_tokens: 9735, + }, + inference_geo: "", + iterations: [], + speed: "standard", + }, + modelUsage: { + "claude-sonnet-4-6": { + inputTokens: 8, + outputTokens: 299, + cacheReadInputTokens: 16100, + cacheCreationInputTokens: 11685, + webSearchRequests: 0, + costUSD: 0.053157750000000004, + contextWindow: 200000, + maxOutputTokens: 32000, + }, + }, + permission_denials: [], + fast_mode_state: "off", + uuid: "d533f969-f62e-4a9e-ad09-cdc3b8a677c3", + }; + }, + }; + + const wrapped = wrapClaudeAgentSDK(mockSDK, { + client, + tracingEnabled: true, + }); + const result: unknown[] = []; + for await (const message of wrapped.query({ + prompt: "Get current time using oracle subagent", + options: { + allowedTools: ["Bash", "Agent"], + }, + })) { + result.push(message); + } + + const res = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(res).toMatchObject({ + nodes: [ + "claude.conversation:0", + "claude.assistant.turn:1", + "Agent:2", + "oracle:3", + "Bash:4", + "claude.assistant.turn:5", + "claude.assistant.turn:6", + ], + edges: [ + ["claude.conversation:0", "claude.assistant.turn:1"], + ["claude.conversation:0", "Agent:2"], + ["Agent:2", "oracle:3"], + ["oracle:3", "Bash:4"], + ["oracle:3", "claude.assistant.turn:5"], + ["claude.conversation:0", "claude.assistant.turn:6"], + ], + }); + }); +}); diff --git a/src/lib/tests/client.int.test.ts b/src/lib/tests/client.int.test.ts new file mode 100644 index 0000000..ebcf7cc --- /dev/null +++ b/src/lib/tests/client.int.test.ts @@ -0,0 +1,2588 @@ +import { + Dataset, + Example, + ExampleUpdateWithAttachments, + Feedback, + Run, + TracerSession, +} from "../schemas.js"; +import { LangSmithConflictError } from "../utils/error.js"; +import { + FunctionMessage, + HumanMessage, + SystemMessage, +} from "@langchain/core/messages"; + +import { Client } from "../client.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { ExampleCreate } from "../schemas.js"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + createRunsFactory, + deleteDataset, + deleteProject, + pollRunsUntilCount, + toArray, + waitUntil, + skipIfTransientError, +} from "./utils.js"; +import { requiresBetaDataset, requiresClickhouse } from "./utils/markers.js"; +import { ChatPromptTemplate, PromptTemplate } from "@langchain/core/prompts"; +import { ChatOpenAI } from "@langchain/openai"; +import { RunnableSequence } from "@langchain/core/runnables"; +import { load } from "langchain/load"; +import { _getFetchImplementation } from "../singletons/fetch.js"; +import { traceable } from "../traceable.js"; + +type CheckOutputsType = boolean | ((run: Run) => boolean); +async function waitUntilRunFound( + client: Client, + runId: string, + checkOutputs: CheckOutputsType = false, +) { + return waitUntil( + async () => { + try { + const run = await client.readRun(runId); + if (checkOutputs) { + const hasOutputs = run.outputs !== null && run.outputs !== undefined; + if (typeof checkOutputs === "boolean") { + return hasOutputs; + } else if (typeof checkOutputs === "function") { + return hasOutputs && checkOutputs(run); + } + } + return true; + } catch (_e) { + return false; + } + }, + 210_000, + 5_000, + ); +} + +// Test Dataset Creation, List, Read, Delete + upload CSV +// Test Example Creation, List, Read, Update, Delete +test("Test LangSmith Client Dataset CRD", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + + const csvContent = `col1,col2,col3,col4\nval1,val2,val3,val4`; + const blobData = new Blob([Buffer.from(csvContent)]); + + const description = "Test Dataset"; + const inputKeys = ["col1", "col3"]; + const outputKeys = ["col2", "col4"]; + const fileName = "__some_file.int.csv"; + await deleteDataset(client, fileName); + const newDataset = await client.uploadCsv({ + csvFile: blobData, + fileName: fileName, + description, + inputKeys, + outputKeys, + }); + expect(newDataset).toHaveProperty("id"); + expect(newDataset.description).toBe(description); + + const dataset = await client.readDataset({ datasetId: newDataset.id }); + const datasetId = dataset.id; + const dataset2 = await client.readDataset({ datasetId }); + expect(dataset.id).toBe(dataset2.id); + + const datasets = await toArray( + client.listDatasets({ datasetName: fileName }), + ); + expect(datasets.length).toBeGreaterThan(0); + expect(datasets.map((d) => d.id)).toContain(datasetId); + + const example = await client.createExample( + { col1: "addedExampleCol1" }, + { col2: "addedExampleCol2" }, + { datasetId: newDataset.id, split: "my_split" }, + ); + const exampleValue = await client.readExample(example.id); + expect(exampleValue.inputs.col1).toBe("addedExampleCol1"); + expect(exampleValue.outputs?.col2).toBe("addedExampleCol2"); + + const examples = await toArray( + client.listExamples({ datasetId: newDataset.id }), + ); + expect(examples.length).toBe(2); + expect(examples.map((e) => e.id)).toContain(example.id); + + const _examples = await toArray( + client.listExamples({ datasetId: newDataset.id, splits: ["my_split"] }), + ); + expect(_examples.length).toBe(1); + expect(_examples.map((e) => e.id)).toContain(example.id); + + await client.updateExample(example.id, { + inputs: { col1: "updatedExampleCol1" }, + outputs: { col2: "updatedExampleCol2" }, + split: ["my_split2"], + }); + // Says 'example updated' or something similar + const newExampleValue = await client.readExample(example.id); + expect(newExampleValue.inputs.col1).toBe("updatedExampleCol1"); + expect(newExampleValue.metadata?.dataset_split).toStrictEqual(["my_split2"]); + + await client.updateExample(example.id, { + inputs: { col1: "updatedExampleCol3" }, + outputs: { col2: "updatedExampleCol4" }, + split: "my_split3", + }); + // Says 'example updated' or something similar + const newExampleValue2 = await client.readExample(example.id); + expect(newExampleValue2.inputs.col1).toBe("updatedExampleCol3"); + expect(newExampleValue2.metadata?.dataset_split).toStrictEqual(["my_split3"]); + + const newExample = await client.createExample( + { col1: "newAddedExampleCol1" }, + { col2: "newAddedExampleCol2" }, + { datasetId: newDataset.id }, + ); + const newExampleValue_ = await client.readExample(newExample.id); + expect(newExampleValue_.inputs.col1).toBe("newAddedExampleCol1"); + expect(newExampleValue_.outputs?.col2).toBe("newAddedExampleCol2"); + + await client.updateExamples([ + { + id: newExample.id, + inputs: { col1: "newUpdatedExampleCol1" }, + outputs: { col2: "newUpdatedExampleCol2" }, + metadata: { foo: "baz" }, + }, + { + id: example.id, + inputs: { col1: "newNewUpdatedExampleCol" }, + outputs: { col2: "newNewUpdatedExampleCol2" }, + metadata: { foo: "qux" }, + }, + ]); + const updatedExample = await client.readExample(newExample.id); + expect(updatedExample.inputs.col1).toBe("newUpdatedExampleCol1"); + expect(updatedExample.outputs?.col2).toBe("newUpdatedExampleCol2"); + expect(updatedExample.metadata?.foo).toBe("baz"); + + const updatedExample2 = await client.readExample(example.id); + expect(updatedExample2.inputs.col1).toBe("newNewUpdatedExampleCol"); + expect(updatedExample2.outputs?.col2).toBe("newNewUpdatedExampleCol2"); + expect(updatedExample2.metadata?.foo).toBe("qux"); + + await client.deleteExample(example.id); + const examples2 = await toArray( + client.listExamples({ datasetId: newDataset.id }), + ); + expect(examples2.length).toBe(2); + await client.createExample({ dataset_id: newDataset.id, inputs: {} }); + + const examples3 = await toArray( + client.listExamples({ datasetId: newDataset.id }), + ); + expect(examples3.length).toBe(3); + + await client.deleteDataset({ datasetId }); + const rawDataset = await client.createDataset(fileName, { + description: "Test Dataset", + }); + await client.deleteDataset({ datasetId: rawDataset.id }); +}); + +test("test create dataset", async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const datasetName = "__test_create_dataset JS"; + const datasets = await toArray(langchainClient.listDatasets({ datasetName })); + datasets.map(async (dataset: Dataset) => { + if (dataset.name === datasetName) { + await langchainClient.deleteDataset({ datasetName }); + } + }); + const dataset = await langchainClient.createDataset(datasetName, { + dataType: "llm", + metadata: { key: "valuefoo" }, + }); + await langchainClient.createExample( + { input: "hello world" }, + { output: "hi there" }, + { + datasetId: dataset.id, + }, + ); + const loadedDataset = await langchainClient.readDataset({ datasetName }); + expect(loadedDataset.data_type).toEqual("llm"); + + const datasetsByMetadata = await toArray( + langchainClient.listDatasets({ metadata: { key: "valuefoo" } }), + ); + expect(datasetsByMetadata.length).toEqual(1); + expect(datasetsByMetadata.map((d) => d.id)).toContain(dataset.id); + await langchainClient.deleteDataset({ datasetName }); +}, 180_000); + +test("evaluators generated client CRUD", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const evaluatorName = `__test_evaluator_${uuidv4().slice(0, 8)}`; + let evaluatorId: string | undefined; + + try { + const created = await client.evaluators.create({ + name: evaluatorName, + type: "code", + code_evaluator: { + code: "def perform_eval(run, example):\n return {'score': 1}", + language: "python", + }, + }); + + evaluatorId = created.evaluator?.id; + expect(evaluatorId).toBeDefined(); + expect(created.evaluator?.name).toBe(evaluatorName); + expect(created.evaluator?.type).toBe("code"); + + const retrieved = await client.evaluators.retrieve(evaluatorId!); + expect(retrieved.id).toBe(evaluatorId); + expect(retrieved.name).toBe(evaluatorName); + } finally { + if (evaluatorId) { + await client.evaluators.delete(evaluatorId, { + delete_run_rules: true, + }); + } + } +}, 180_000); + +requiresClickhouse.test( + "Test share and unshare run", + async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + + // Create a new run + const runId = uuidv4(); + await langchainClient.createRun({ + name: "Test run", + inputs: { input: "hello world" }, + run_type: "chain", + id: runId, + }); + + await waitUntilRunFound(langchainClient, runId); + const sharedUrl = await langchainClient.shareRun(runId); + const response = await _getFetchImplementation()(sharedUrl); + expect(response.status).toEqual(200); + expect(await langchainClient.readRunSharedLink(runId)).toEqual(sharedUrl); + + await langchainClient.unshareRun(runId); + const sharedLink = await langchainClient.readRunSharedLink(runId); + expect(sharedLink).toBe(undefined); + }, + 180_000, +); + +test("Test list datasets", async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const datasetName1 = "___TEST dataset1 JS"; + const datasetName2 = "___TEST dataset2 JS"; + await deleteDataset(langchainClient, datasetName1); + await deleteDataset(langchainClient, datasetName2); + // Create two new datasets + const dataset1 = await langchainClient.createDataset(datasetName1, { + dataType: "llm", + }); + const dataset2 = await langchainClient.createDataset(datasetName2, { + dataType: "kv", + }); + + // List datasets by ID + const datasetsById: Dataset[] = []; + const datasetsByIdIterable = langchainClient.listDatasets({ + datasetIds: [dataset1.id, dataset2.id], + }); + for await (const dataset of datasetsByIdIterable) { + datasetsById.push(dataset); + } + expect(datasetsById).toHaveLength(2); + expect(datasetsById.map((dataset) => dataset.id)).toContain(dataset1.id); + expect(datasetsById.map((dataset) => dataset.id)).toContain(dataset2.id); + + // List datasets by name + const datasetsByNameIterable = langchainClient.listDatasets({ + datasetName: datasetName1, + }); + const datasetsByName = []; + for await (const dataset of datasetsByNameIterable) { + datasetsByName.push(dataset); + } + expect(datasetsByName).toHaveLength(1); + expect(datasetsByName.map((dataset) => dataset.id)).toContain(dataset1.id); + + await langchainClient.deleteDataset({ datasetId: dataset1.id }); + await langchainClient.deleteDataset({ datasetId: dataset2.id }); +}, 180_000); + +requiresClickhouse.test( + "Test create feedback with source run", + async () => { + await skipIfTransientError(async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const projectName = "__test_create_feedback_with_source_run JS"; + if (await langchainClient.hasProject({ projectName })) { + await deleteProject(langchainClient, projectName); + } + const runId = uuidv4(); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { prompt: "hello world" }, + outputs: { generation: "hi there" }, + start_time: new Date().getTime(), + end_time: new Date().getTime(), + }); + + const runId2 = uuidv4(); + await langchainClient.createRun({ + id: runId2, + project_name: projectName, + name: "test_run_2", + run_type: "llm", + inputs: { prompt: "hello world 2" }, + outputs: { generation: "hi there 2" }, + start_time: new Date().getTime(), + end_time: new Date().getTime(), + }); + + // Wait for runs to be ingested before creating feedback + await Promise.all([ + waitUntilRunFound(langchainClient, runId, true), + waitUntilRunFound(langchainClient, runId2, true), + ]); + + await langchainClient.createFeedback(runId, "test_feedback", { + score: 0.5, + sourceRunId: runId2, + feedbackSourceType: "app", + }); + await langchainClient.createFeedback(runId2, "test_feedback_2", { + score: 0.5, + feedbackSourceType: "app", + }); + + // Poll for feedbacks to be available (up to 10 seconds) + let feedbacks: Feedback[] = []; + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + feedbacks = await toArray( + langchainClient.listFeedback({ + runIds: [runId, runId2], + }), + ); + if (feedbacks.length >= 2) break; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + + expect(feedbacks).toHaveLength(2); + expect(feedbacks.map((f) => f.run_id)).toContain(runId); + expect(feedbacks.map((f) => f.run_id)).toContain(runId2); + }); + }, + 180_000, +); + +requiresClickhouse.test( + "Test create run with masked inputs/outputs", + async () => { + const langchainClient = new Client({ + hideInputs: true, + hideOutputs: true, + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const projectName = "__test_create_run_with_masked_inputs_outputs JS"; + await deleteProject(langchainClient, projectName); + const runId = uuidv4(); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run", + run_type: "llm", + inputs: { prompt: "hello world" }, + outputs: { generation: "hi there" }, + start_time: new Date().getTime(), + end_time: new Date().getTime(), + }); + + const runId2 = uuidv4(); + await langchainClient.createRun({ + id: runId2, + project_name: projectName, + name: "test_run_2", + run_type: "llm", + inputs: { messages: "hello world 2" }, + start_time: new Date().getTime(), + }); + + await langchainClient.updateRun(runId2, { + outputs: { generation: "hi there 2" }, + end_time: new Date().getTime(), + }); + await waitUntilRunFound(langchainClient, runId, false); + const run1 = await langchainClient.readRun(runId); + expect(Object.keys(run1.inputs ?? {})).toHaveLength(0); + expect(Object.keys(run1.outputs ?? {})).toHaveLength(0); + await waitUntilRunFound(langchainClient, runId2, false); + const run2 = await langchainClient.readRun(runId2); + expect(Object.keys(run2.inputs ?? {})).toHaveLength(0); + expect(Object.keys(run2.outputs ?? {})).toHaveLength(0); + }, + 240_000, +); + +// TODO: investigate - revision_id metadata not being set consistently +// Environment variables (LANGCHAIN_REVISION_ID) may not be properly propagating to the API +test.skip("Test create run with revision id", async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + + try { + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_REVISION_ID = "test_revision_id"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_OTHER_FIELD = "test_other_field"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_OTHER_KEY = "test_other_key"; + const projectName = "__test_create_run_with_revision_id JS"; + await deleteProject(langchainClient, projectName); + const runId = uuidv4(); + await langchainClient.createRun({ + id: runId, + project_name: projectName, + name: "test_run_with_revision", + run_type: "llm", + inputs: { prompt: "hello world" }, + outputs: { generation: "hi there" }, + start_time: new Date().getTime(), + end_time: new Date().getTime(), + }); + + const runId2 = uuidv4(); + await langchainClient.createRun({ + id: runId2, + project_name: projectName, + name: "test_run_2_with_revision", + run_type: "llm", + inputs: { messages: "hello world 2" }, + start_time: new Date().getTime(), + revision_id: "different_revision_id", + }); + + // Wait for both runs to be fully persisted + await waitUntilRunFound( + langchainClient, + runId, + (run: Run | undefined) => Object.keys(run?.outputs || {}).length !== 0, + ); + await waitUntilRunFound(langchainClient, runId2); + + // Add a delay to ensure metadata is fully propagated (sometimes takes longer in CI) + await new Promise((resolve) => setTimeout(resolve, 3000)); + + const run1 = await langchainClient.readRun(runId); + expect(run1.extra?.metadata?.revision_id).toEqual("test_revision_id"); + expect(run1.extra?.metadata.LANGCHAIN_OTHER_FIELD).toEqual( + "test_other_field", + ); + expect(run1.extra?.metadata.LANGCHAIN_OTHER_KEY).toBeUndefined(); + expect(run1.extra?.metadata).not.toHaveProperty("LANGCHAIN_API_KEY"); + + const run2 = await langchainClient.readRun(runId2); + expect(run2.extra?.metadata?.revision_id).toEqual("different_revision_id"); + expect(run2.extra?.metadata.LANGCHAIN_OTHER_FIELD).toEqual( + "test_other_field", + ); + expect(run2.extra?.metadata.LANGCHAIN_OTHER_KEY).toBeUndefined(); + expect(run2.extra?.metadata).not.toHaveProperty("LANGCHAIN_API_KEY"); + } finally { + // Clean up environment variables + // eslint-disable-next-line no-process-env + delete process.env.LANGCHAIN_REVISION_ID; + // eslint-disable-next-line no-process-env + delete process.env.LANGCHAIN_OTHER_FIELD; + // eslint-disable-next-line no-process-env + delete process.env.LANGCHAIN_OTHER_KEY; + } +}, 180_000); + +describe("createChatExample", () => { + it("should convert LangChainBaseMessage objects to examples", async () => { + const langchainClient = new Client({ + autoBatchTracing: false, + // Test the fetch options option + fetchOptions: { cache: "no-store" }, + }); + + const datasetName = `__createChatExample-test-dataset JS ${uuidv4()}`; + const dataset = await langchainClient.createDataset(datasetName); + + try { + const input = [new HumanMessage({ content: "Hello, world!" })]; + const generation = new FunctionMessage({ + name: "foo", + content: "", + additional_kwargs: { + function_call: { arguments: "args", name: "foo" }, + }, + }); + const options = { datasetId: dataset.id }; + + // Create the example from messages + await langchainClient.createChatExample(input, generation, options); + + // Read the example + const examples = []; + for await (const example of langchainClient.listExamples({ + datasetId: dataset.id, + })) { + examples.push(example); + } + expect(examples.length).toBe(1); + expect(examples[0].inputs).toEqual({ + input: [ + { + type: "human", + data: { content: "Hello, world!" }, + }, + ], + }); + expect(examples[0].outputs).toEqual({ + output: { + type: "function", + data: { + content: "", + additional_kwargs: { + function_call: { arguments: "args", name: "foo" }, + }, + }, + }, + }); + } finally { + await langchainClient.deleteDataset({ datasetId: dataset.id }); + } + }, 180_000); +}); + +requiresClickhouse.test( + "Test getRunUrl with run", + async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const runId = uuidv4(); + const run: Run = { + id: runId, + name: "foo", + run_type: "llm", + inputs: { input: "hello world" }, + outputs: { output: "hi there" }, + }; + await client.createRun({ project_name: "foo", ...run }); + await waitUntilRunFound( + client, + runId, + (run: Run | undefined) => Object.keys(run?.outputs || {}).length !== 0, + ); + const result = await client.getRunUrl({ + run, + projectOpts: { projectName: "foo" }, + }); + expect(result).toContain(runId); + }, + 180_000, +); + +test("Examples CRUD", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const datasetName = "__test_examples_crud JS" + Date.now(); + await deleteDataset(client, datasetName); + const dataset = await client.createDataset(datasetName); + const example = await client.createExample( + { input: "hello world" }, + { output: "hi there" }, + { + datasetId: dataset.id, + metadata: { key: "value" }, + }, + ); + const exampleValue = await client.readExample(example.id); + const initialVersion = exampleValue.modified_at; + expect(exampleValue.inputs.input).toEqual("hello world"); + expect(exampleValue?.outputs?.output).toEqual("hi there"); + expect(exampleValue?.metadata?.key).toEqual("value"); + + // Update the example by modifying the metadata + await client.updateExample(example.id, { + metadata: { key: "new value" }, + }); + const updatedExampleValue = await client.readExample(example.id); + expect(updatedExampleValue?.metadata?.key).toEqual("new value"); + + // Create multiple + await client.createExamples({ + inputs: [ + { input: "hello world 1" }, + { input: "hello world 2" }, + { input: "hello world 3" }, + ], + outputs: [ + { output: "hi there 1" }, + { output: "hi there 2" }, + { output: "hi there 3" }, + ], + metadata: [{ key: "value 1" }, { key: "value 2" }, { key: "value 3" }], + splits: ["train", "test", ["train", "validation"]], + datasetId: dataset.id, + }); + const initialExamplesList = await toArray( + client.listExamples({ datasetId: dataset.id, asOf: initialVersion }), + ); + expect(initialExamplesList.length).toEqual(1); + const examplesList = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + expect(examplesList.length).toEqual(4); + + const examplesListLimited = await toArray( + client.listExamples({ datasetId: dataset.id, limit: 2 }), + ); + expect(examplesListLimited.length).toEqual(2); + + const examplesListOffset = await toArray( + client.listExamples({ datasetId: dataset.id, offset: 2 }), + ); + expect(examplesListOffset.length).toEqual(2); + + const examplesListLimitedOffset = await toArray( + client.listExamples({ datasetId: dataset.id, limit: 1, offset: 2 }), + ); + expect(examplesListLimitedOffset.length).toEqual(1); + + await client.deleteExample(example.id); + const examplesList2 = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + expect(examplesList2.length).toEqual(3); + const datasetDiff = await client.diffDatasetVersions({ + datasetId: dataset.id, + fromVersion: initialVersion!, + toVersion: "latest", + }); + expect(datasetDiff.examples_added.length).toEqual(3); + expect(datasetDiff.examples_modified.length).toEqual(0); + expect(datasetDiff.examples_removed.length).toEqual(1); + + // verify the example inputs, outputs, and metadata + const example1 = examplesList2.find( + (e) => e.inputs.input === "hello world 1", + ); + expect(example1?.outputs?.output).toEqual("hi there 1"); + expect(example1?.metadata?.key).toEqual("value 1"); + expect(example1?.metadata?.dataset_split).toEqual(["train"]); + const example2 = examplesList2.find( + (e) => e.inputs.input === "hello world 2", + ); + expect(example2?.outputs?.output).toEqual("hi there 2"); + expect(example2?.metadata?.key).toEqual("value 2"); + expect(example2?.metadata?.dataset_split).toEqual(["test"]); + const example3 = examplesList2.find( + (e) => e.inputs.input === "hello world 3", + ); + expect(example3?.outputs?.output).toEqual("hi there 3"); + expect(example3?.metadata?.key).toEqual("value 3"); + expect(example3?.metadata?.dataset_split).toContain("train"); + expect(example3?.metadata?.dataset_split).toContain("validation"); + + await client.createExample( + { input: "hello world" }, + { output: "hi there" }, + { + datasetId: dataset.id, + metadata: { foo: "bar", baz: "qux" }, + }, + ); + let examplesList3 = await toArray( + client.listExamples({ datasetId: dataset.id, metadata: { foo: "bar" } }), + ); + expect(examplesList3.length).toEqual(1); + expect(examplesList3[0].metadata?.foo).toEqual("bar"); + expect(examplesList3[0].metadata?.baz).toEqual("qux"); + + examplesList3 = await toArray( + client.listExamples({ datasetId: dataset.id, metadata: { foo: "qux" } }), + ); + expect(examplesList3.length).toEqual(0); + + examplesList3 = await toArray( + client.listExamples({ datasetId: dataset.id, metadata: { baz: "qux" } }), + ); + expect(examplesList3.length).toEqual(1); + + expect(examplesList3[0].metadata?.foo).toEqual("bar"); + expect(examplesList3[0].metadata?.baz).toEqual("qux"); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + metadata: { foo: "bar", baz: "qux" }, + }), + ); + expect(examplesList3.length).toEqual(1); + expect(examplesList3[0].metadata?.foo).toEqual("bar"); + expect(examplesList3[0].metadata?.baz).toEqual("qux"); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + filter: 'exists(metadata, "baz")', + }), + ); + expect(examplesList3.length).toEqual(1); + expect(examplesList3[0].metadata?.foo).toEqual("bar"); + expect(examplesList3[0].metadata?.baz).toEqual("qux"); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + filter: 'has("metadata", \'{"foo": "bar"}\')', + }), + ); + expect(examplesList3.length).toEqual(1); + expect(examplesList3[0].metadata?.foo).toEqual("bar"); + expect(examplesList3[0].metadata?.baz).toEqual("qux"); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + filter: 'exists(metadata, "bazzz")', + }), + ); + expect(examplesList3.length).toEqual(0); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + splits: ["train"], + }), + ); + expect(examplesList3.length).toEqual(2); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + splits: ["test"], + }), + ); + expect(examplesList3.length).toEqual(1); + + examplesList3 = await toArray( + client.listExamples({ + datasetId: dataset.id, + splits: ["train", "test"], + }), + ); + expect(examplesList3.length).toEqual(3); + + // Test batch soft delete - at the end so it doesn't affect other tests + const allExamples = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + const exampleIdsToDelete = allExamples.slice(0, 2).map((e) => e.id); + await client.deleteExamples(exampleIdsToDelete); + const examplesAfterBatchDelete = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + expect(examplesAfterBatchDelete.length).toEqual(allExamples.length - 2); + + // Test hard delete + const remainingExamples = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + if (remainingExamples.length > 0) { + const hardDeleteIds = [remainingExamples[0].id]; + await client.deleteExamples(hardDeleteIds, { hardDelete: true }); + const examplesAfterHardDelete = await toArray( + client.listExamples({ datasetId: dataset.id }), + ); + expect(examplesAfterHardDelete.length).toEqual( + remainingExamples.length - 1, + ); + } + + await client.deleteDataset({ datasetId: dataset.id }); +}, 180_000); + +requiresClickhouse.test( + "list runs limit arg works", + async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + + const projectName = `test-limit-runs-${uuidv4().substring(0, 4)}`; + const limit = 6; + + // delete the project just in case + if (await client.hasProject({ projectName })) { + await client.deleteProject({ projectName }); + } + + try { + const runsArr: Array = []; + // create a fresh project with 10 runs --default amount created by createRunsFactory + await client.createProject({ projectName }); + await Promise.all( + createRunsFactory(projectName).map(async (payload) => { + if (!payload.id) payload.id = uuidv4(); + await client.createRun(payload); + await waitUntilRunFound(client, payload.id); + }), + ); + + let iters = 0; + for await (const run of client.listRuns({ limit, projectName })) { + expect(run).toBeDefined(); + runsArr.push(run); + iters += 1; + if (iters > limit) { + throw new Error( + `More runs returned than expected.\nExpected: ${limit}\nReceived: ${iters}`, + ); + } + } + + expect(runsArr.length).toBe(limit); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (e: any) { + if (e.message.startsWith("More runs returned than expected.")) { + throw e; + } else { + console.error(e); + } + } finally { + if (await client.hasProject({ projectName })) { + await client.deleteProject({ projectName }); + } + } + }, + 180_000, +); // Increased timeout for creating/waiting for 10 runs + +test("Test run stats", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const stats = await client.getRunStats({ + projectNames: ["default"], + runType: "llm", + }); + expect(stats).toBeDefined(); +}); + +test("Test createProject raises LangSmithConflictError on duplicate name", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const projectName = `test_project_${uuidv4()}`; + + try { + // Create the project for the first time + await client.createProject({ projectName }); + + // Attempt to create the project with the same name again + await expect(client.createProject({ projectName })).rejects.toThrow( + expect.objectContaining({ + name: "LangSmithConflictError", + }), + ); + } finally { + try { + // Clean up: delete the project + if (await client.hasProject({ projectName })) { + await client.deleteProject({ projectName }); + } + } catch (_e) { + // Everyone has those days. + } + } +}); + +test("Test list prompts", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const uid = uuidv4(); + // push 3 prompts + const promptName1 = `test_prompt_${uid}__0`; + const promptName2 = `test_prompt_${uid}__1`; + const promptName3 = `test_prompt_${uid}__2`; + + await client.pushPrompt(promptName1, { + object: ChatPromptTemplate.fromMessages( + [ + new SystemMessage({ content: "System message" }), + new HumanMessage({ content: "{{question}}" }), + ], + { templateFormat: "mustache" }, + ), + isPublic: true, + }); + await client.pushPrompt(promptName2, { + object: ChatPromptTemplate.fromMessages( + [ + new SystemMessage({ content: "System message" }), + new HumanMessage({ content: "{{question}}" }), + ], + { templateFormat: "mustache" }, + ), + }); + await client.pushPrompt(promptName3, { + object: ChatPromptTemplate.fromMessages( + [ + new SystemMessage({ content: "System message" }), + new HumanMessage({ content: "{{question}}" }), + ], + { templateFormat: "mustache" }, + ), + }); + + // expect at least one of the prompts to have promptName1 + const response = client.listPrompts({ isPublic: true, query: uid }); + let found = false; + expect(response).toBeDefined(); + for await (const prompt of response) { + expect(prompt).toBeDefined(); + if (prompt.repo_handle === promptName1) { + found = true; + } + } + expect(found).toBe(true); + + // expect the prompts to be sorted by updated_at + const response2 = client.listPrompts({ sortField: "updated_at", query: uid }); + expect(response2).toBeDefined(); + let lastUpdatedAt: number | undefined; + for await (const prompt of response2) { + expect(prompt.updated_at).toBeDefined(); + const currentUpdatedAt = new Date(prompt.updated_at).getTime(); + if (lastUpdatedAt !== undefined) { + expect(currentUpdatedAt).toBeLessThanOrEqual(lastUpdatedAt); + } + lastUpdatedAt = currentUpdatedAt; + } + expect(lastUpdatedAt).toBeDefined(); +}); + +// Consolidated: exercises the full prompt lifecycle against a single prompt +// repo: push + exists + get + update + commit + like/unlike + pullPromptCommit +// + push/pull with metadata + delete + exists-after-delete. Previously split +// across "Test get prompt", "Test prompt exists", "Test update prompt", +// "Test delete prompt", "Test create commit", "Test like and unlike prompt", +// "Test pull prompt commit", and "Test push and pull prompt". +test("Prompt CRUD lifecycle (push, get, exists, update, commit, like/unlike, pull, delete)", async () => { + // No global prompt cache: its refresh timer would keep polling the deleted + // prompt after this file finishes, and jest fails the run on late logs. + const client = new Client({ + callerOptions: { maxRetries: 6 }, + disablePromptCache: true, + }); + + const promptName = `test_prompt_lifecycle_${uuidv4().slice(0, 8)}`; + const initialTemplate = ChatPromptTemplate.fromMessages( + [ + new SystemMessage({ content: "System message" }), + new HumanMessage({ content: "{{question}}" }), + ], + { templateFormat: "mustache" }, + ); + const updatedTemplate = ChatPromptTemplate.fromMessages( + [ + new SystemMessage({ content: "System message" }), + new HumanMessage({ content: "My question is: {{question}}" }), + ], + { templateFormat: "mustache" }, + ); + + try { + // 1) Non-existent prompts should report as not-existing. + const nonExistentPrompt = `non_existent_${uuidv4().slice(0, 8)}`; + expect(await client.promptExists(nonExistentPrompt)).toBe(false); + + // 2) Push initial version with metadata. + const pushUrl = await client.pushPrompt(promptName, { + object: initialTemplate, + description: "Test description", + readme: "Test readme", + tags: ["test", "tag"], + }); + expect(pushUrl).toBeDefined(); + + // 3) exists / get reflect the newly pushed prompt. + expect(await client.promptExists(promptName)).toBe(true); + const prompt = await client.getPrompt(promptName); + expect(prompt).toBeDefined(); + expect(prompt?.repo_handle).toBe(promptName); + expect(prompt?.description).toBe("Test description"); + expect(prompt?.readme).toBe("Test readme"); + expect(prompt?.tags).toEqual(expect.arrayContaining(["test", "tag"])); + expect(prompt?.is_public).toBe(false); + + // 4) updatePrompt changes description / isPublic / tags. + const updatedData = await client.updatePrompt(promptName, { + description: "Updated description", + isPublic: true, + tags: ["test", "update"], + }); + expect(updatedData).toBeDefined(); + const updatedPrompt = await client.getPrompt(promptName); + expect(updatedPrompt?.description).toBe("Updated description"); + expect(updatedPrompt?.is_public).toBe(true); + expect(updatedPrompt?.tags).toEqual( + expect.arrayContaining(["test", "update"]), + ); + + // 5) createCommit records a new commit URL referencing the prompt. + const commitUrl = await client.createCommit(promptName, updatedTemplate); + expect(commitUrl).toBeDefined(); + expect(commitUrl).toContain(promptName); + + // 6) like / unlike toggles num_likes. + await client.likePrompt(promptName); + expect((await client.getPrompt(promptName))?.num_likes).toBe(1); + await client.unlikePrompt(promptName); + expect((await client.getPrompt(promptName))?.num_likes).toBe(0); + + // 7) pullPromptCommit returns the latest commit for this repo. + const promptCommit = await client.pullPromptCommit(promptName); + expect(promptCommit).toBeDefined(); + expect(promptCommit.repo).toBe(promptName); + + // 8) Pushing again without additional options still succeeds; _pullPrompt + // returns the underlying manifest. + // Note: This may throw a LangSmithConflictError if the content hasn't changed + // (e.g., if the same template was already committed in step 5). + try { + await client.pushPrompt(promptName, { object: updatedTemplate }); + } catch (e) { + if (!(e instanceof LangSmithConflictError)) { + throw e; + } + // Conflict is expected if the same template was already committed + } + const pulledPrompt = await client._pullPrompt(promptName); + expect(pulledPrompt).toBeDefined(); + } finally { + // 9) Delete and confirm it no longer exists. + await client.deletePrompt(promptName); + expect(await client.promptExists(promptName)).toBe(false); + } +}); + +test("test listing projects by metadata", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const uid = uuidv4(); + const projectName = `my_metadata_project_${uid}`; + + await client.createProject({ + projectName: projectName, + metadata: { + foobar: uid, + baz: "barfooqux", + }, + }); + + const projects = await client.listProjects({ metadata: { foobar: uid } }); + + let myProject: TracerSession | null = null; + for await (const project of projects) { + myProject = project; + } + expect(myProject?.name).toEqual(projectName); + + await client.deleteProject({ projectName: projectName }); +}); + +// This test requires OPENAI_API_KEY to be set and valid +test("Test pull prompt include model", async () => { + // eslint-disable-next-line no-process-env + const openaiKey = process.env.OPENAI_API_KEY; + if (!openaiKey || openaiKey.trim() === "" || openaiKey === "placeholder") { + console.log("⚠️ Test skipped: OPENAI_API_KEY not set or invalid"); + return; + } + + // No global prompt cache: its refresh timer would keep polling the deleted + // prompt after this file finishes, and jest fails the run on late logs. + const client = new Client({ + callerOptions: { maxRetries: 6 }, + disablePromptCache: true, + }); + let promptName: string | undefined; + try { + const model = new ChatOpenAI({}); + const promptTemplate = PromptTemplate.fromTemplate( + "Tell me a joke about {topic}", + ); + const promptWithModel = promptTemplate.pipe(model); + + promptName = `test_prompt_with_model_${uuidv4().slice(0, 8)}`; + await client.pushPrompt(promptName, { object: promptWithModel }); + + const pulledPrompt = await client._pullPrompt(promptName, { + includeModel: true, + }); + const rs: RunnableSequence = await load(pulledPrompt); + expect(rs).toBeDefined(); + expect(rs).toBeInstanceOf(RunnableSequence); + } catch (error: any) { + // Skip test if it fails due to missing/invalid API key during deserialization + if (error?.message?.includes("Missing secret")) { + console.log( + "⚠️ Test skipped: OPENAI_API_KEY not valid for model loading", + ); + return; + } + throw error; + } finally { + if (promptName) { + try { + await client.deletePrompt(promptName); + } catch { + // Ignore cleanup errors + } + } + } +}); + +// The share token below only resolves on the beta tenant. +requiresBetaDataset.test( + "list shared examples can list shared examples", + async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const multiverseMathPublicDatasetShareToken = + "cce9c8a9-761a-4756-b159-58ed2640e274"; + const sharedExamples = await client.listSharedExamples( + multiverseMathPublicDatasetShareToken, + ); + expect(sharedExamples.length).toBeGreaterThan(0); + }, +); + +// Clones from a beta.smith.langchain.com public URL, absent on self-hosted. +requiresBetaDataset.test( + "clonePublicDataset method can clone a dataset", + async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = "multiverse_math_public_testing"; + const multiverseMathPublicDatasetURL = + "https://beta.smith.langchain.com/public/cce9c8a9-761a-4756-b159-58ed2640e274/d"; + + try { + await client.clonePublicDataset(multiverseMathPublicDatasetURL, { + datasetName, + }); + + const clonedDataset = await client.hasDataset({ datasetName }); + expect(clonedDataset).toBe(true); + + const examples: Example[] = []; + for await (const ex of client.listExamples({ datasetName })) { + examples.push(ex); + } + expect(examples.length).toBeGreaterThan(0); + } finally { + try { + // Attempt to remove the newly created dataset if successful. + await client.deleteDataset({ datasetName }); + } catch (_) { + // no-op if failure + } + } + }, +); + +requiresClickhouse.test("annotationqueue crud", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const queueName = `test-queue-${uuidv4().substring(0, 8)}`; + const projectName = `test-project-${uuidv4().substring(0, 8)}`; + const queueId = uuidv4(); + + try { + // 1. Create an annotation queue + const queue = await client.createAnnotationQueue({ + name: queueName, + description: "Initial description", + queueId, + }); + expect(queue).toBeDefined(); + expect(queue.name).toBe(queueName); + + // 1a. Get the annotation queue + const fetchedQueue = await client.readAnnotationQueue(queue.id); + expect(fetchedQueue).toBeDefined(); + expect(fetchedQueue.name).toBe(queueName); + + // 1b. List annotation queues and check nameContains + const listedQueues = await toArray( + client.listAnnotationQueues({ nameContains: queueName }), + ); + expect(listedQueues.length).toBeGreaterThan(0); + expect(listedQueues.some((q) => q.id === queue.id)).toBe(true); + + // 2. Create a run in a random project + await client.createProject({ projectName }); + const runId = uuidv4(); + await client.createRun({ + id: runId, + name: "Test Run", + run_type: "chain", + inputs: { foo: "bar" }, + outputs: { baz: "qux" }, + project_name: projectName, + }); + + // Wait for run to be found in the db + const maxWaitTime = 30000; // 30 seconds + const startTime = Date.now(); + let foundRun = null; + + while (Date.now() - startTime < maxWaitTime) { + try { + foundRun = await client.readRun(runId); + if (foundRun) break; + } catch (_error) { + // If run is not found, getRun might throw an error + // We'll ignore it and keep trying + } + await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait for 1 second before trying again + } + + if (!foundRun) { + throw new Error( + `Run with ID ${runId} not found after ${maxWaitTime / 1000} seconds`, + ); + } + + // 3. Add the run to the annotation queue + await client.addRunsToAnnotationQueue(fetchedQueue.id, [runId]); + + // 4. Update the annotation queue description and check that it is updated + const newDescription = "Updated description"; + await client.updateAnnotationQueue(queue.id, { + name: queueName, + description: newDescription, + }); + const updatedQueue = await client.readAnnotationQueue(queue.id); + expect(updatedQueue.description).toBe(newDescription); + + // Get the run from the annotation queue + const run = await client.getRunFromAnnotationQueue(queueId, 0); + expect(run).toBeDefined(); + expect(run.id).toBe(runId); + expect(run.name).toBe("Test Run"); + expect(run.run_type).toBe("chain"); + expect(run.inputs).toEqual({ foo: "bar" }); + expect(run.outputs).toEqual({ baz: "qux" }); + + // List runs in the annotation queue + const listed = await toArray(client.listRunsFromAnnotationQueue(queueId)); + expect(listed.length).toBe(1); + expect(listed[0].id).toBe(runId); + + // Limit should cap the number of yielded runs + const limited = await toArray( + client.listRunsFromAnnotationQueue(queueId, { limit: 1 }), + ); + expect(limited.length).toBe(1); + } finally { + // 6. Delete the annotation queue + await client.deleteAnnotationQueue(queueId); + + // Clean up the project + if (await client.hasProject({ projectName })) { + await client.deleteProject({ projectName }); + } + } +}); + +requiresClickhouse.test("annotationqueue add runs by key", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const queueName = `test-queue-${uuidv4().substring(0, 8)}`; + const projectName = `test-project-${uuidv4().substring(0, 8)}`; + const queueId = uuidv4(); + + try { + const queue = await client.createAnnotationQueue({ + name: queueName, + description: "by-key queue", + queueId, + }); + + await client.createProject({ projectName }); + const runId = uuidv4(); + await client.createRun({ + id: runId, + name: "Test Run", + run_type: "chain", + inputs: { foo: "bar" }, + project_name: projectName, + }); + + // Wait for the run to be found in the db. + const maxWaitTime = 30000; + const startWait = Date.now(); + let foundRun = null; + while (Date.now() - startWait < maxWaitTime) { + try { + foundRun = await client.readRun(runId); + if (foundRun) break; + } catch (_error) { + // keep retrying until the run shows up + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + if (!foundRun) { + throw new Error( + `Run with ID ${runId} not found after ${maxWaitTime / 1000} seconds`, + ); + } + + // Add the run using its SmithDB partition key. + await client.addRunsToAnnotationQueue(queue.id, [ + { + runId: foundRun.id, + sessionId: foundRun.session_id as string, + startTime: foundRun.start_time as string | number, + }, + ]); + + const listed = await toArray(client.listRunsFromAnnotationQueue(queueId)); + expect(listed.length).toBe(1); + expect(listed[0].id).toBe(runId); + } finally { + await client.deleteAnnotationQueue(queueId); + if (await client.hasProject({ projectName })) { + await client.deleteProject({ projectName }); + } + } +}); + +// Consolidated: exercises rubric_instructions behavior across both creation +// paths (unset vs set at creation time) plus the update path, using a single +// pair of queues. Previously split across +// "annotationqueue crud with rubric instructions" and +// "annotationqueue crud with rubric instructions 2". +test("annotationqueue rubric instructions (unset, set at create, update)", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + + // Queue A: created WITHOUT rubric instructions, then updated to have them. + const queueAName = `test-queue-${uuidv4().substring(0, 8)}`; + const queueAId = uuidv4(); + + // Queue B: created WITH rubric instructions, then updated. + const queueBName = `test-queue-${uuidv4().substring(0, 8)}`; + const queueBId = uuidv4(); + + try { + const queueA = await client.createAnnotationQueue({ + name: queueAName, + description: "Initial description", + queueId: queueAId, + }); + expect(queueA).toBeDefined(); + expect(queueA.name).toBe(queueAName); + expect(queueA.rubric_instructions).toBeUndefined(); + + const fetchedQueueA = await client.readAnnotationQueue(queueA.id); + expect(fetchedQueueA.name).toBe(queueAName); + expect(fetchedQueueA.rubric_instructions).toBeNull(); + + const queueB = await client.createAnnotationQueue({ + name: queueBName, + description: "Initial description", + queueId: queueBId, + rubricInstructions: "This is a rubric instruction", + }); + expect(queueB).toBeDefined(); + expect(queueB.name).toBe(queueBName); + + const fetchedQueueB = await client.readAnnotationQueue(queueB.id); + expect(fetchedQueueB.name).toBe(queueBName); + expect(fetchedQueueB.rubric_instructions).toBe( + "This is a rubric instruction", + ); + + // Update path: set on previously-unset, and overwrite on previously-set. + const newInstructions = "Updated rubric instructions"; + await client.updateAnnotationQueue(queueA.id, { + name: queueAName, + rubricInstructions: newInstructions, + }); + await client.updateAnnotationQueue(queueB.id, { + name: queueBName, + rubricInstructions: newInstructions, + }); + expect( + (await client.readAnnotationQueue(queueA.id)).rubric_instructions, + ).toBe(newInstructions); + expect( + (await client.readAnnotationQueue(queueB.id)).rubric_instructions, + ).toBe(newInstructions); + } finally { + await client.deleteAnnotationQueue(queueAId); + await client.deleteAnnotationQueue(queueBId); + } +}); + +test("feedback config crud", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const feedbackKey = `test-feedback-config-${uuidv4().substring(0, 8)}`; + + try { + // 1. Create a continuous feedback config + const config = await client.createFeedbackConfig({ + feedbackKey, + feedbackConfig: { type: "continuous", min: 0, max: 1 }, + isLowerScoreBetter: false, + }); + expect(config).toBeDefined(); + expect(config.feedback_key).toBe(feedbackKey); + expect(config.feedback_config.type).toBe("continuous"); + expect(config.feedback_config.min).toBe(0); + expect(config.feedback_config.max).toBe(1); + expect(config.is_lower_score_better).toBe(false); + + // 2. List and verify + const configs: any[] = []; + for await (const c of client.listFeedbackConfigs({ + feedbackKeys: [feedbackKey], + })) { + configs.push(c); + } + expect(configs.length).toBe(1); + expect(configs[0].feedback_key).toBe(feedbackKey); + + // 3. Update is_lower_score_better + const updated = await client.updateFeedbackConfig(feedbackKey, { + isLowerScoreBetter: true, + }); + expect(updated.is_lower_score_better).toBe(true); + + // 4. Upsert (create with same config should return existing) + const upserted = await client.createFeedbackConfig({ + feedbackKey, + feedbackConfig: { type: "continuous", min: 0, max: 1 }, + isLowerScoreBetter: true, + }); + expect(upserted.feedback_key).toBe(feedbackKey); + + // 5. Delete + await client.deleteFeedbackConfig(feedbackKey); + + // 6. Verify deleted + const remaining: any[] = []; + for await (const c of client.listFeedbackConfigs({ + feedbackKeys: [feedbackKey], + })) { + remaining.push(c); + } + expect(remaining.length).toBe(0); + } catch (e) { + // Clean up on failure + try { + await client.deleteFeedbackConfig(feedbackKey); + } catch { + // ignore cleanup errors + } + throw e; + } +}, 180_000); + +test("upload examples multipart", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_upload_examples_multipart${uuidv4().slice(0, 4)}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + // Create test examples + const exampleId = uuidv4(); + const example1: ExampleCreate = { + id: exampleId, + inputs: { text: "hello world" }, + // check that passing no outputs works fine + attachments: { + test_file: { + mimeType: "image/png", + data: fs.readFileSync(pathname), + }, + }, + }; + + const example2: ExampleCreate = { + inputs: { text: "foo bar" }, + outputs: { response: "baz" }, + attachments: { + my_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + // Test creating examples + const createdExamples = await client.uploadExamplesMultipart(dataset.id, [ + example1, + example2, + ]); + + expect(createdExamples.count).toBe(2); + expect(createdExamples.as_of).toBeDefined(); + expect(typeof createdExamples.as_of).toBe("string"); + if (createdExamples.as_of) { + expect(new Date(createdExamples.as_of).getTime()).toBeLessThanOrEqual( + Date.now(), + ); + } + + const createdExample1 = await client.readExample(exampleId); + expect(createdExample1.inputs["text"]).toBe("hello world"); + + const createdExample2 = await client.readExample( + createdExamples.example_ids.find((id) => id !== exampleId)!, + ); + expect(createdExample2.inputs["text"]).toBe("foo bar"); + expect(createdExample2.outputs?.["response"]).toBe("baz"); + + // Test examples were sent to correct dataset + const allExamplesInDataset = []; + for await (const example of client.listExamples({ + datasetId: dataset.id, + })) { + allExamplesInDataset.push(example); + } + expect(allExamplesInDataset.length).toBe(2); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("update examples multipart", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_update_examples_multipart${uuidv4().slice(0, 4)}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + metadata: { bar: "foo" }, + inputs: { text: "hello world" }, + // check that passing no outputs works fine + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + foo: ["image/png", fs.readFileSync(pathname)], + }, + }; + + // Create examples + await client.uploadExamplesMultipart(dataset.id, [example]); + + const exampleUpdate1: ExampleUpdateWithAttachments = { + id: exampleId, + inputs: { text: "hello world" }, + attachments_operations: { + retain: ["test_file"], + rename: { foo: "test_file" }, + }, + }; + + await expect( + client.updateExamplesMultipart(dataset.id, [exampleUpdate1]), + ).rejects.toThrow(); + + const exampleUpdate2: ExampleUpdateWithAttachments = { + id: exampleId, + inputs: { text: "hello world" }, + attachments_operations: { + retain: ["test_file"], + rename: { test_file: "test_file2" }, + }, + }; + + await expect( + client.updateExamplesMultipart(dataset.id, [exampleUpdate2]), + ).rejects.toThrow(); + + const exampleUpdate3: ExampleUpdateWithAttachments = { + id: exampleId, + inputs: { text: "hello world2" }, + attachments_operations: { + retain: ["test_file"], + rename: { foo: "bar" }, + }, + }; + + const updateResponse = await client.updateExamplesMultipart(dataset.id, [ + exampleUpdate3, + ]); + expect(updateResponse.as_of).toBeDefined(); + expect(typeof updateResponse.as_of).toBe("string"); + if (updateResponse.as_of) { + expect(new Date(updateResponse.as_of).getTime()).toBeLessThanOrEqual( + Date.now(), + ); + } + + let updatedExample = await client.readExample(exampleId); + expect(updatedExample.inputs.text).toEqual("hello world2"); + expect(Object.keys(updatedExample.attachments ?? {}).sort()).toEqual( + ["bar", "test_file"].sort(), + ); + expect(updatedExample.metadata?.bar).toEqual("foo"); + let attachmentData: Uint8Array | undefined = updatedExample.attachments?.[ + "test_file" + ].presigned_url + ? new Uint8Array( + (await fetch( + updatedExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + attachmentData = updatedExample.attachments?.["bar"].presigned_url + ? new Uint8Array( + (await fetch(updatedExample.attachments?.["bar"].presigned_url).then( + (res) => res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + const exampleUpdate4: ExampleUpdateWithAttachments = { + id: exampleId, + metadata: { foo: "bar" }, + attachments: { + test_file2: ["image/png", fs.readFileSync(pathname)], + }, + }; + + await client.updateExamplesMultipart(dataset.id, [exampleUpdate4]); + updatedExample = await client.readExample(exampleId); + expect(updatedExample.metadata?.foo).toEqual("bar"); + expect(Object.keys(updatedExample.attachments ?? {})).toEqual(["test_file2"]); + attachmentData = updatedExample.attachments?.["test_file2"].presigned_url + ? new Uint8Array( + (await fetch( + updatedExample.attachments?.["test_file2"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + const exampleUpdate5: ExampleUpdateWithAttachments = { + id: exampleId, + split: ["foo", "bar"], + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + await client.updateExamplesMultipart(dataset.id, [exampleUpdate5]); + + updatedExample = await client.readExample(exampleId); + expect(updatedExample.metadata).toEqual({ + foo: "bar", + dataset_split: ["foo", "bar"], + }); + expect(Object.keys(updatedExample.attachments ?? {})).toEqual(["test_file"]); + attachmentData = updatedExample.attachments?.["test_file"].presigned_url + ? new Uint8Array( + (await fetch( + updatedExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("create example go backend", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + let createdExample = await client.createExample(example); + expect(createdExample.id).toEqual(exampleId); + expect(createdExample.inputs.text).toEqual("hello world"); + expect(Object.keys(createdExample.attachments ?? {})).toEqual(["test_file"]); + let attachmentData: Uint8Array | undefined = createdExample.attachments?.[ + "test_file" + ].presigned_url + ? new Uint8Array( + (await fetch( + createdExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + createdExample = await client.createExample( + { text: "hello world" }, + { + foo: "bar", + }, + { + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + datasetName: datasetName, + }, + ); + expect(createdExample.inputs.text).toEqual("hello world"); + expect(createdExample.outputs?.foo).toEqual("bar"); + expect(Object.keys(createdExample.attachments ?? {})).toEqual(["test_file"]); + attachmentData = createdExample.attachments?.["test_file"].presigned_url + ? new Uint8Array( + (await fetch( + createdExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +// Backend changes for go endpoint using runs still needs to land +test.skip("test use source run io multiple examples", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + + const datasetName = `__test_use_source_run_io${uuidv4().slice(0, 4)}`; + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for creating dataset with description", + }); + + const runId = uuidv4(); + await client.createRun({ + name: "foo", + run_type: "llm", + inputs: { foo: "bar" }, + outputs: { foo: "bar" }, + id: runId, + attachments: { + test_file: { data: Buffer.from("test content"), mimeType: "text/plain" }, + real_file: { data: Buffer.from("real content"), mimeType: "text/plain" }, + }, + }); + + const exampleIds = [uuidv4(), uuidv4(), uuidv4()]; + await client.createExamples([ + { + id: exampleIds[0], + inputs: { bar: "baz" }, + outputs: { bar: "baz" }, + attachments: { + test_file2: { + data: Buffer.from("test content"), + mimeType: "text/plain", + }, + }, + use_source_run_io: true, + use_source_run_attachments: [], + source_run_id: runId, + dataset_id: dataset.id, + }, + { + id: exampleIds[1], + inputs: { bar: "baz" }, + outputs: { bar: "baz" }, + attachments: { + test_file2: { + data: Buffer.from("test content"), + mimeType: "text/plain", + }, + }, + use_source_run_io: false, + use_source_run_attachments: ["test_file"], + source_run_id: runId, + dataset_id: dataset.id, + }, + { + id: exampleIds[2], + inputs: { bar: "baz" }, + outputs: { bar: "baz" }, + attachments: { + test_file2: { + data: Buffer.from("test content"), + mimeType: "text/plain", + }, + }, + use_source_run_io: true, + use_source_run_attachments: ["test_file"], + source_run_id: runId, + dataset_id: dataset.id, + }, + ]); + + const example1 = await client.readExample(exampleIds[0]); + const example2 = await client.readExample(exampleIds[1]); + const example3 = await client.readExample(exampleIds[2]); + + expect(example1.dataset_id).toBe(dataset.id); + expect(example1.inputs).toEqual({ foo: "bar" }); + expect(example1.outputs).toEqual({ foo: "bar" }); + expect(Object.keys(example1.attachments ?? {}).sort()).toEqual([ + "real_file", + "test_file", + ]); + + expect(example2.dataset_id).toBe(dataset.id); + expect(example2.inputs).toEqual({ bar: "baz" }); + expect(example2.outputs).toEqual({ bar: "baz" }); + expect(Object.keys(example2.attachments ?? {}).sort()).toEqual(["test_file"]); + + expect(example3.dataset_id).toBe(dataset.id); + expect(example3.inputs).toEqual({ foo: "bar" }); + expect(example3.outputs).toEqual({ foo: "bar" }); + expect(Object.keys(example3.attachments ?? {}).sort()).toEqual([ + "real_file", + "test_file", + ]); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +// Backend changes for go endpoint using runs still needs to land +test.skip("test use source run io single example", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const runId = uuidv4(); + await client.createRun({ + name: "foo", + run_type: "llm", + inputs: { foo: "bar" }, + outputs: { foo: "bar" }, + id: runId, + attachments: { + test_file: { data: Buffer.from("test content"), mimeType: "text/plain" }, + real_file: { data: Buffer.from("real content"), mimeType: "text/plain" }, + }, + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file2: ["image/png", fs.readFileSync(pathname)], + }, + use_source_run_io: true, + use_source_run_attachments: [], + source_run_id: runId, + }; + + let createdExample = await client.createExample(example); + expect(createdExample.id).toEqual(exampleId); + expect(createdExample.inputs.text).toEqual("hello world"); + // using source run io overrides everything else with regards to attachments + expect(Object.keys(createdExample.attachments ?? {})).toEqual([ + "real_file", + "test_file", + ]); + + createdExample = await client.createExample( + { text: "hello world" }, + { + foo: "bar", + }, + { + attachments: { + test_file2: ["image/png", fs.readFileSync(pathname)], + }, + datasetName: datasetName, + useSourceRunIO: true, + useSourceRunAttachments: [], + sourceRunId: runId, + }, + ); + expect(createdExample.inputs.text).toEqual("hello world"); + expect(createdExample.outputs?.foo).toEqual("bar"); + // using source run io overrides everything else with regards to attachments + expect(Object.keys(createdExample.attachments ?? {})).toEqual([ + "real_file", + "test_file", + ]); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("update example go backend", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + await client.createExample(example); + + // Update old way + await client.updateExample(exampleId, { + attachments: { + test_file3: ["image/png", fs.readFileSync(pathname)], + }, + attachments_operations: { + rename: { test_file: "test_file2" }, + }, + }); + + let retrievedExample = await client.readExample(exampleId); + expect(retrievedExample.inputs.text).toEqual("hello world"); + expect(Object.keys(retrievedExample.attachments ?? {}).sort()).toEqual( + ["test_file2", "test_file3"].sort(), + ); + + // Update new way + await client.updateExample({ + id: exampleId, + attachments: { + test_file4: ["image/png", fs.readFileSync(pathname)], + }, + attachments_operations: { + rename: { test_file3: "test_file5" }, + }, + }); + retrievedExample = await client.readExample(exampleId); + expect(retrievedExample.inputs.text).toEqual("hello world"); + expect(Object.keys(retrievedExample.attachments ?? {}).sort()).toEqual( + ["test_file4", "test_file5"].sort(), + ); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("create examples go backend", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + let createdExample = (await client.createExamples([example]))[0]; + expect(createdExample.id).toEqual(exampleId); + expect(createdExample.inputs.text).toEqual("hello world"); + expect(Object.keys(createdExample.attachments ?? {})).toEqual(["test_file"]); + let attachmentData: Uint8Array | undefined = createdExample.attachments?.[ + "test_file" + ].presigned_url + ? new Uint8Array( + (await fetch( + createdExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + createdExample = ( + await client.createExamples({ + inputs: [{ text: "hello world" }], + outputs: [ + { + foo: "bar", + }, + ], + attachments: [ + { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + ], + datasetName: datasetName, + }) + )[0]; + expect(createdExample.inputs.text).toEqual("hello world"); + expect(createdExample.outputs?.foo).toEqual("bar"); + expect(Object.keys(createdExample.attachments ?? {})).toEqual(["test_file"]); + attachmentData = createdExample.attachments?.["test_file"].presigned_url + ? new Uint8Array( + (await fetch( + createdExample.attachments?.["test_file"].presigned_url, + ).then((res) => res.arrayBuffer())) as ArrayBuffer, + ) + : undefined; + expect(attachmentData).toEqual(new Uint8Array(fs.readFileSync(pathname))); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("update examples go backend", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "parrot-icon.png", + ); + + // Create test examples + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file: ["image/png", fs.readFileSync(pathname)], + }, + }; + + await client.createExample(example); + + // Update + await client.updateExamples([ + { + id: exampleId, + attachments: { + test_file3: ["image/png", fs.readFileSync(pathname)], + }, + attachments_operations: { + rename: { test_file: "test_file2" }, + }, + }, + ]); + + const retrievedExample = await client.readExample(exampleId); + expect(retrievedExample.inputs.text).toEqual("hello world"); + expect(Object.keys(retrievedExample.attachments ?? {}).sort()).toEqual( + ["test_file2", "test_file3"].sort(), + ); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +test("create example errors", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_create_examples_go_backend${uuidv4().slice( + 0, + 4, + )}`; + + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + const exampleId = uuidv4(); + const example: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + inputs: { text: "hello world" }, + attachments: { + test_file: ["plain/txt", Buffer.from("data")], + }, + }; + + const invalidExample: ExampleCreate = { + id: exampleId, + inputs: { text: "hello world" }, + attachments: { + test_file: ["plain/txt", Buffer.from("data")], + }, + }; + + const invalidExample2: ExampleCreate = { + id: exampleId, + dataset_id: dataset.id, + dataset_name: datasetName, + inputs: { text: "hello world" }, + attachments: { + test_file: ["plain/txt", Buffer.from("data")], + }, + }; + + await expect( + client.createExample(example, { foo: "bar" }, {}), + ).rejects.toThrow( + "Cannot provide outputs or options when using ExampleCreate object", + ); + + await expect(client.createExample(invalidExample)).rejects.toThrow( + "Must provide either datasetName or datasetId", + ); + + await expect(client.createExample(invalidExample2)).rejects.toThrow( + "Must provide either datasetName or datasetId, not both", + ); + + await expect(client.createExample({ foo: "bar" }, {}, {})).rejects.toThrow( + "Must provide either datasetName or datasetId", + ); + + await expect( + client.createExample( + { foo: "bar" }, + {}, + { datasetId: dataset.id, datasetName: datasetName }, + ), + ).rejects.toThrow("Must provide either datasetName or datasetId, not both"); + + // Clean up + await client.deleteDataset({ datasetName }); +}); + +requiresClickhouse.test("fetch child runs", async () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const projectName = `__test_fetch_child_runs_${uuidv4().slice(0, 4)}`; + await deleteProject(client, projectName); + try { + const parentRunId = uuidv4(); + const childRunId = uuidv4(); + const parent = traceable( + async () => { + const child = traceable( + async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return "From child"; + }, + { + client, + id: childRunId, + }, + ); + const result = await child(); + return result + "|From parent"; + }, + { + client, + id: parentRunId, + }, + ); + const result = await parent(); + expect(result).toEqual("From child|From parent"); + + await client.awaitPendingTraceBatches(); + await waitUntilRunFound(client, parentRunId); + + const noChildRun = await client.readRun(parentRunId); + expect(noChildRun.id).toEqual(parentRunId); + expect(noChildRun.child_runs?.length).toBeUndefined(); + + const run = await client.readRun(parentRunId, { loadChildRuns: true }); + expect(run.child_runs?.length).toEqual(1); + expect(run.child_runs?.[0].id).toEqual(childRunId); + expect(run.child_runs?.[0].outputs?.outputs).toEqual("From child"); + } finally { + await deleteProject(client, projectName); + } +}); + +// Consolidated: covers listThreads (threads grouped by thread_id) and +// readThread (runs for a single thread_id) using a single project and set of +// runs. Previously split across +// "listThreads returns threads grouped by thread_id" and +// "readThread yields runs for a single thread_id". +requiresClickhouse.test( + "listThreads groups runs by thread_id and readThread yields per-thread runs", + async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const projectName = `test-list-threads-${uuidv4().slice(0, 12)}`; + if (await client.hasProject({ projectName })) { + await deleteProject(client, projectName); + } + try { + const base = uuidv4().slice(0, 8); + const threadA = `thread-${base}-a`; + const threadB = `thread-${base}-b`; + const now = new Date(); + const threadMeta = (tid: string) => ({ + metadata: { + thread_id: tid, + session_id: null, + conversation_id: null, + }, + }); + await client.createRun({ + name: "run_a1", + inputs: { x: 1 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime(), + extra: threadMeta(threadA), + }); + await client.createRun({ + name: "run_a2", + inputs: { x: 2 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime() + 1000, + extra: threadMeta(threadA), + }); + await client.createRun({ + name: "run_b1", + inputs: { y: 1 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime() + 2000, + extra: threadMeta(threadB), + }); + await pollRunsUntilCount(client, projectName, 3, 30_000); + const threads = await client.listThreads({ + projectName, + limit: 10, + }); + expect(Array.isArray(threads)).toBe(true); + expect(threads.length).toBeGreaterThanOrEqual(2); + for (const item of threads) { + expect(item).toHaveProperty("thread_id"); + expect(item).toHaveProperty("runs"); + expect(item).toHaveProperty("count"); + expect(item).toHaveProperty("min_start_time"); + expect(item).toHaveProperty("max_start_time"); + expect(Array.isArray(item.runs)).toBe(true); + expect(item.count).toEqual(item.runs.length); + } + const threadIds = new Set(threads.map((t) => t.thread_id)); + expect(threadIds.has(threadA)).toBe(true); + expect(threadIds.has(threadB)).toBe(true); + const threadAItem = threads.find((t) => t.thread_id === threadA); + const threadBItem = threads.find((t) => t.thread_id === threadB); + expect(threadAItem).toBeDefined(); + expect(threadBItem).toBeDefined(); + expect(threadAItem!.count).toEqual(2); + expect(threadBItem!.count).toEqual(1); + + // readThread should yield only the runs belonging to the requested thread. + const readRunsA: Run[] = []; + for await (const run of client.readThread({ + threadId: threadA, + projectName, + limit: 10, + })) { + readRunsA.push(run); + } + expect(readRunsA.length).toEqual(2); + const readNamesA = new Set(readRunsA.map((r) => r.name)); + expect(readNamesA.has("run_a1")).toBe(true); + expect(readNamesA.has("run_a2")).toBe(true); + + const readRunsB: Run[] = []; + for await (const run of client.readThread({ + threadId: threadB, + projectName, + limit: 10, + })) { + readRunsB.push(run); + } + expect(readRunsB.length).toEqual(1); + expect(readRunsB[0].name).toBe("run_b1"); + } finally { + if (await client.hasProject({ projectName })) { + await deleteProject(client, projectName); + } + } + }, + 60_000, +); + +// --------------------------------------------------------------------------- +// v2 OpenAPI client resources +// --------------------------------------------------------------------------- + +function v2ProjectName(suffix: string): string { + return `__test_v2_resources_${suffix}_${uuidv4().replace(/-/g, "")}`; +} + +async function getV2ProjectId( + client: Client, + proj: string, + maxRetries = 30, + sleepMs = 2000, +): Promise { + for (let i = 0; i < maxRetries; i++) { + try { + const project = await client.readProject({ projectName: proj }); + return String(project.id); + } catch (e: any) { + const msg = String(e?.message ?? e); + if (msg.includes("403") || msg.includes("projects:read")) { + return null; + } + } + await new Promise((r) => setTimeout(r, sleepMs)); + } + throw new Error(`Project "${proj}" not found after ${maxRetries} retries`); +} + +async function postV2Trace( + client: Client, + proj: string, +): Promise<{ traceId: string; projectId: string | null; startTime: Date }> { + const traceId = uuidv4(); + const startTime = new Date(); + await client.createRun({ + id: traceId, + name: "root_run", + run_type: "chain", + inputs: { input: "hello" }, + outputs: { result: "ok" }, + start_time: startTime.getTime(), + end_time: new Date().getTime(), + project_name: proj, + }); + const projectId = await getV2ProjectId(client, proj); + return { traceId, projectId, startTime }; +} + +describe("client.runs v2 resource", () => { + let client: Client; + + beforeAll(() => { + client = new Client(); + }); + + test("queryV2 (alias: query)", async () => { + const proj = v2ProjectName("runs_query"); + const { traceId, projectId } = await postV2Trace(client, proj); + if (!projectId) { + console.warn( + "SKIPPED: requires projects:read permission (service key limitation)", + ); + await deleteProject(client, proj); + return; + } + let runs: unknown[] = []; + for (let i = 0; i < 30; i++) { + runs = []; + for await (const run of client.runs.queryV2({ + project_ids: [projectId], + selects: ["ID", "NAME", "RUN_TYPE", "TRACE_ID"], + })) { + runs.push(run); + } + if (runs.length >= 1) break; + await new Promise((r) => setTimeout(r, 2000)); + } + expect(runs.length).toBeGreaterThanOrEqual(1); + const traceIds = runs.map((r: any) => r.trace_id); + expect(traceIds).toContain(traceId); + await deleteProject(client, proj); + }); + + test("retrieveV2 (alias: retrieve)", async () => { + const proj = v2ProjectName("runs_retrieve"); + const { traceId, projectId, startTime } = await postV2Trace(client, proj); + if (!projectId) { + console.warn( + "SKIPPED: requires projects:read permission (service key limitation)", + ); + await deleteProject(client, proj); + return; + } + let run: any; + for (let i = 0; i < 30; i++) { + try { + run = await client.runs.retrieveV2(traceId, { + project_id: projectId, + start_time: startTime.toISOString(), + }); + if (run?.id === traceId) break; + } catch { + // not indexed yet + } + await new Promise((r) => setTimeout(r, 2000)); + } + expect(run?.id).toBe(traceId); + await deleteProject(client, proj); + }); +}); diff --git a/src/lib/tests/client.test.ts b/src/lib/tests/client.test.ts new file mode 100644 index 0000000..2713339 --- /dev/null +++ b/src/lib/tests/client.test.ts @@ -0,0 +1,2599 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-process-env */ +import { jest } from "@jest/globals"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { inspect } from "node:util"; +import { + Client, + ClientConfig, + mergeRuntimeEnvIntoRun, + _checkBackendVersion, +} from "../client.js"; +import { v4 as uuid } from "../utils/uuid/src/index.js"; +import type { KVMap, RunCreate } from "../schemas.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { + getLangSmithEnvironmentVariables, + getLangSmithEnvVarsMetadata, +} from "../utils/env.js"; +import { parseHubIdentifier } from "../utils/prompts.js"; +import { _resetWarnedMessages } from "../utils/warn.js"; +import { isSampledById } from "../utils/sampling.js"; + +describe("Client", () => { + describe.each(["readExample", "listExamples"] as const)( + "%s attachment URLs", + (method) => { + test.each([ + [ + "/api/v1/public/download?jwt=test-token", + "https://smith.example.test/api/v1/public/download?jwt=test-token", + ], + [ + "/langsmith/api/v1/public/download?jwt=test-token", + "https://smith.example.test/langsmith/api/v1/public/download?jwt=test-token", + ], + [ + "https://storage.example.test/file?signature=a%2Fb&expires=123", + "https://storage.example.test/file?signature=a%2Fb&expires=123", + ], + ])("resolves %s", async (presignedUrl, expectedUrl) => { + const exampleId = "550e8400-e29b-41d4-a716-446655440000"; + const datasetId = "550e8400-e29b-41d4-a716-446655440001"; + const rawExample = { + id: exampleId, + dataset_id: datasetId, + inputs: {}, + attachment_urls: { + "attachment.file": { + presigned_url: presignedUrl, + mime_type: "text/plain", + }, + }, + }; + const mockFetch = jest + .fn() + .mockResolvedValue( + Response.json(method === "readExample" ? rawExample : [rawExample]), + ); + const client = new Client({ + apiUrl: "https://smith.example.test/api/v1", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + const example = + method === "readExample" + ? await client.readExample(exampleId) + : ( + await client + .listExamples({ + datasetId, + includeAttachments: true, + limit: 1, + }) + [Symbol.asyncIterator]() + .next() + ).value; + + expect(example.attachments?.file).toEqual({ + presigned_url: expectedUrl, + mime_type: "text/plain", + }); + }); + }, + ); + + describe("resource tags on create", () => { + const tagValueIds = [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001", + ]; + + it("includes tag value IDs when creating a project", async () => { + const mockFetch = jest.fn().mockResolvedValue( + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + + await client.createProject({ projectName: "test-project", tagValueIds }); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init?.body as string).tag_value_ids).toEqual( + tagValueIds, + ); + }); + + it("includes tag value IDs when creating a dataset", async () => { + const mockFetch = jest.fn().mockResolvedValue( + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + + await client.createDataset("test-dataset", { tagValueIds }); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init?.body as string).tag_value_ids).toEqual( + tagValueIds, + ); + }); + }); + + describe("createFeedback", () => { + it("can opt out of extending trace retention", async () => { + const mockFetch = jest.fn().mockResolvedValue( + new Response("{}", { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + + await client.createFeedback( + "550e8400-e29b-41d4-a716-446655440000", + "Foo", + { + score: 1, + extendTraceRetention: false, + sessionId: "550e8400-e29b-41d4-a716-446655440001", + }, + ); + + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init?.body as string)).toEqual( + expect.objectContaining({ + extend_trace_retention: false, + }), + ); + }); + + it("uses the supplied experiment session ID for evaluation feedback", async () => { + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + }); + const createFeedback = jest + .spyOn(client, "createFeedback") + .mockResolvedValue({} as any); + const startTime = "2026-07-21T12:34:56.789Z"; + + await client.logEvaluationFeedback( + { key: "quality", score: 1 }, + { + id: "550e8400-e29b-41d4-a716-446655440000", + start_time: startTime, + } as any, + undefined, + "550e8400-e29b-41d4-a716-446655440001", + ); + + expect(createFeedback).toHaveBeenCalledWith( + "550e8400-e29b-41d4-a716-446655440000", + "quality", + expect.objectContaining({ + sessionId: "550e8400-e29b-41d4-a716-446655440001", + startTime, + }), + ); + }); + + it("applies run ID sampling before sending feedback", async () => { + const mockFetch = jest.fn().mockResolvedValue( + new Response("{}", { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + tracingSamplingRate: 0.5, + }); + + // sessionId is supplied so the call never consults GET /info, leaving + // mockFetch to count feedback requests alone. + const sessionId = "550e8400-e29b-41d4-a716-446655440001"; + const sampledFeedback = await client.createFeedback( + "00000000-0000-0000-0000-000000000015", + "Foo", + { score: 1, sessionId }, + ); + const filteredFeedback = await client.createFeedback( + "00000000-0000-0000-0000-000000000004", + "Foo", + { score: 0, sessionId }, + ); + + expect(sampledFeedback.run_id).toBe( + "00000000-0000-0000-0000-000000000015", + ); + expect(filteredFeedback.run_id).toBe( + "00000000-0000-0000-0000-000000000004", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init?.body as string)).toEqual( + expect.objectContaining({ + run_id: "00000000-0000-0000-0000-000000000015", + }), + ); + }); + + it("passes the run's trace ID through evaluation feedback", async () => { + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + }); + const createFeedback = jest + .spyOn(client, "createFeedback") + .mockResolvedValue({} as any); + const run = { + id: "550e8400-e29b-41d4-a716-446655440000", + trace_id: "550e8400-e29b-41d4-a716-4466554400ff", + } as any; + + // Scoring the run itself: its trace is known, so it is forwarded. + await client.logEvaluationFeedback({ key: "quality", score: 1 }, run); + expect(createFeedback).toHaveBeenLastCalledWith( + run.id, + "quality", + expect.objectContaining({ traceId: run.trace_id }), + ); + + // targetRunId names a different run, whose trace we cannot know from + // `run`. Sampling must fall back to the run id rather than guess. + await client.logEvaluationFeedback( + { + key: "quality", + score: 1, + targetRunId: "550e8400-e29b-41d4-a716-446655440099", + }, + run, + ); + expect(createFeedback).toHaveBeenLastCalledWith( + "550e8400-e29b-41d4-a716-446655440099", + "quality", + expect.objectContaining({ traceId: undefined }), + ); + }); + + it("samples feedback by trace ID, not run ID", async () => { + const mockFetch = jest.fn().mockResolvedValue( + new Response("{}", { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + tracingSamplingRate: 0.5, + }); + const sessionId = "550e8400-e29b-41d4-a716-446655440001"; + // A child run whose own id would be filtered out on its own. + const childId = "00000000-0000-0000-0000-000000000004"; + + // Sampled-in trace: the child's feedback goes out with it. + await client.createFeedback(childId, "Foo", { + score: 1, + sessionId, + traceId: "00000000-0000-0000-0000-000000000015", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [, init] = mockFetch.mock.calls[0]; + expect(JSON.parse(init?.body as string)).toEqual( + expect.objectContaining({ + run_id: childId, + trace_id: "00000000-0000-0000-0000-000000000015", + }), + ); + + // Filtered-out trace: nothing further is sent. + await client.createFeedback(childId, "Foo", { + score: 0, + sessionId, + traceId: "00000000-0000-0000-0000-000000000004", + }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + const infoClient = (smithdbOnly: boolean) => { + const instance_flags = smithdbOnly + ? { ch_query_enabled: false, sdb_query_enabled: true } + : { ch_query_enabled: true }; + const mockFetch = jest.fn().mockImplementation( + async () => + new Response(JSON.stringify({ instance_flags }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:1984", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + const paths = () => + mockFetch.mock.calls.map(([url]) => new URL(String(url)).pathname); + return { client, paths }; + }; + + it("throws without a sessionId on a SmithDB-only deployment", async () => { + const { client, paths } = infoClient(true); + + await expect( + client.createFeedback("550e8400-e29b-41d4-a716-446655440000", "Foo", { + score: 1, + }), + ).rejects.toThrow(/sessionId must be provided/); + // Only GET /info was called: the feedback was never sent. + expect(paths()).toEqual(["/info"]); + + // startTime stays optional, matching the server. + await client.createFeedback( + "550e8400-e29b-41d4-a716-446655440000", + "Foo", + { + score: 1, + sessionId: "550e8400-e29b-41d4-a716-446655440001", + }, + ); + expect(paths()).toContain("/feedback"); + + // Session-level feedback has no run to locate. + await client.createFeedback(null, "Foo", { + score: 1, + projectId: "550e8400-e29b-41d4-a716-446655440001", + }); + }); + + it("accepts the params-object overload, which requires sessionId", async () => { + const { client, paths } = infoClient(true); + + await client.createFeedback({ + runId: "550e8400-e29b-41d4-a716-446655440000", + key: "Foo", + score: 1, + sessionId: "550e8400-e29b-41d4-a716-446655440001", + }); + // sessionId came from the params, so /info was never consulted. + expect(paths()).toEqual(["/feedback"]); + + await client.createFeedback({ + key: "Foo", + score: 1, + projectId: "550e8400-e29b-41d4-a716-446655440001", + }); + + // @ts-expect-error sessionId is required alongside runId. + await client.createFeedback({ runId: "x", key: "Foo" }).catch(() => {}); + }); + + it("warns without a sessionId on other deployments", async () => { + const { client, paths } = infoClient(false); + // warnOnce dedupes per process, so this must be the only test that warns. + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + + await client.createFeedback( + "550e8400-e29b-41d4-a716-446655440000", + "Foo", + { + score: 1, + }, + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + "smithdb-sdk-migration-feedback#feedback-create", + ), + ); + expect(paths()).toContain("/feedback"); + warn.mockRestore(); + }); + }); + + describe("evaluators", () => { + it("creates an evaluator through the platform endpoint", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce( + // first call: _checkStainlessVersion triggers GET /info + new Response(JSON.stringify({ version: "0.16.14" }), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + // second call: the actual evaluator create + new Response( + JSON.stringify({ + evaluator: { + id: "eval-1", + name: "SDK smoke test code evaluator", + type: "code", + }, + }), + { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }, + ), + ); + const client = new Client({ + apiUrl: "http://localhost:8080", + apiKey: "test-api-key", + workspaceId: "test-workspace-id", + fetchImplementation: mockFetch, + }); + + const response = await client.evaluators.create({ + name: "SDK smoke test code evaluator", + type: "code", + code_evaluator: { + code: "def perform_eval(run, example):\n return {'score': 1}", + language: "python", + }, + }); + + expect(response.evaluator?.id).toBe("eval-1"); + const [url, init] = mockFetch.mock.calls[1]; // call[0] is the /info prefetch + const headers = new Headers(init?.headers); + expect(url).toBe("http://localhost:8080/api/v1/platform/evaluators"); + expect(init).toEqual( + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ + name: "SDK smoke test code evaluator", + type: "code", + code_evaluator: { + code: "def perform_eval(run, example):\n return {'score': 1}", + language: "python", + }, + }), + }), + ); + expect(headers.get("content-type")).toBe("application/json"); + expect(headers.get("x-api-key")).toBe("test-api-key"); + expect(headers.get("x-tenant-id")).toBe("test-workspace-id"); + }); + }); + + describe("annotationQueues", () => { + it("exposes generated annotation queue item endpoints", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce( + // first call: _checkStainlessVersion triggers GET /info + new Response(JSON.stringify({ version: "0.16.14" }), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ count: 3 }), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:8080", + apiKey: "test-api-key", + workspaceId: "test-workspace-id", + fetchImplementation: mockFetch, + }); + + const response = await client.annotationQueues.items.retrieveCount( + "queue-1", + { status: "all" }, + ); + + expect(response.count).toBe(3); + const [url, init] = mockFetch.mock.calls[1]; // call[0] is the /info prefetch + const headers = new Headers(init?.headers); + expect(url).toBe( + "http://localhost:8080/api/v1/platform/annotation-queues/queue-1/items/count?status=all", + ); + expect(init).toEqual(expect.objectContaining({ method: "GET" })); + expect(headers.get("x-api-key")).toBe("test-api-key"); + expect(headers.get("x-tenant-id")).toBe("test-workspace-id"); + }); + }); + + describe("createLLMExample", () => { + it("should create an example with the given input and generation", async () => { + const client = new Client({ apiKey: "test-api-key" }); + const createExampleSpy = jest + .spyOn(client, "createExample") + .mockResolvedValue({ + id: "test-example-id", + dataset_id: "test-dataset-id", + inputs: {}, + outputs: { text: "Bonjour, monde!" }, + created_at: "2022-01-01T00:00:00.000Z", + modified_at: "2022-01-01T00:00:00.000Z", + runs: [], + }); + + const input = "Hello, world!"; + const generation = "Bonjour, monde!"; + const options = { datasetName: "test-dataset" }; + + await client.createLLMExample(input, generation, options); + expect(createExampleSpy).toHaveBeenCalledWith( + { input }, + { output: generation }, + options, + ); + }); + }); + + describe("createChatExample", () => { + it("should convert LangChainBaseMessage objects to examples", async () => { + const client = new Client({ apiKey: "test-api-key" }); + const createExampleSpy = jest + .spyOn(client, "createExample") + .mockResolvedValue({ + id: "test-example-id", + dataset_id: "test-dataset-id", + inputs: {}, + outputs: { text: "Bonjour", sender: "bot" }, + created_at: "2022-01-01T00:00:00.000Z", + modified_at: "2022-01-01T00:00:00.000Z", + runs: [], + }); + + const input = [ + { text: "Hello", sender: "user" }, + { text: "Hi there", sender: "bot" }, + ]; + const generations = { + type: "langchain", + data: { text: "Bonjour", sender: "bot" }, + }; + const options = { datasetName: "test-dataset" }; + + await client.createChatExample(input, generations, options); + + expect(createExampleSpy).toHaveBeenCalledWith( + { + input: [ + { text: "Hello", sender: "user" }, + { text: "Hi there", sender: "bot" }, + ], + }, + { + output: { + data: { text: "Bonjour", sender: "bot" }, + type: "langchain", + }, + }, + options, + ); + }); + }); + + it("should trim trailing slash on a passed apiUrl", () => { + const client = new Client({ apiUrl: "https://example.com/" }); + const result = (client as any).apiUrl; + expect(result).toBe("https://example.com"); + }); + + describe("profile config", () => { + const originalEnv = process.env; + let tempDir: string; + + const writeProfileConfig = (config: unknown) => { + const configPath = path.join(tempDir, "config.json"); + fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`); + process.env.LANGSMITH_CONFIG_FILE = configPath; + return configPath; + }; + + beforeEach(() => { + process.env = { ...originalEnv }; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "langsmith-profile-")); + delete process.env.LANGSMITH_API_KEY; + delete process.env.LANGCHAIN_API_KEY; + delete process.env.LANGSMITH_ENDPOINT; + delete process.env.LANGCHAIN_ENDPOINT; + delete process.env.LANGSMITH_WORKSPACE_ID; + delete process.env.LANGCHAIN_WORKSPACE_ID; + delete process.env.LANGSMITH_PROFILE; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + process.env = originalEnv; + }); + + it("loads api URL, API key header, and workspace from the active profile", () => { + writeProfileConfig({ + current_profile: "prod", + profiles: { + prod: { + api_key: "profile-key", + api_url: "https://profile.example.com", + workspace_id: "workspace-id", + }, + }, + }); + + const client = new Client(); + + expect((client as any).apiUrl).toBe("https://profile.example.com"); + expect((client as any).apiKey).toBeUndefined(); + expect((client as any).workspaceId).toBe("workspace-id"); + expect((client as any)._mergedHeaders["x-api-key"]).toBe("profile-key"); + expect((client as any)._mergedHeaders["x-tenant-id"]).toBe( + "workspace-id", + ); + }); + + it("logs where the API URL came from when debug is enabled", () => { + const log = jest.spyOn(console, "log").mockImplementation(() => {}); + try { + process.env.LANGSMITH_CONFIG_FILE = path.join(tempDir, "missing.json"); + new Client({ apiKey: "x", debug: true }); + expect(log).toHaveBeenCalledWith( + "LangSmith API URL https://api.smith.langchain.com resolved from built-in default", + ); + + process.env.LANGSMITH_ENDPOINT = "https://eu.api.smith.langchain.com"; + new Client({ apiKey: "x", debug: true }); + expect(log).toHaveBeenCalledWith( + "LangSmith API URL https://eu.api.smith.langchain.com resolved from LANGSMITH_ENDPOINT / LANGCHAIN_ENDPOINT environment variable", + ); + + new Client({ + apiKey: "x", + debug: true, + apiUrl: "https://custom.example.com", + }); + expect(log).toHaveBeenCalledWith( + "LangSmith API URL https://custom.example.com resolved from apiUrl option", + ); + + log.mockClear(); + new Client({ apiKey: "x" }); + expect(log).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + } + }); + + it("uses profile OAuth access tokens before profile API keys", () => { + writeProfileConfig({ + profiles: { + default: { + api_key: "profile-key", + oauth: { + access_token: "profile-access-token", + }, + }, + }, + }); + + const client = new Client(); + + expect((client as any).apiKey).toBeUndefined(); + expect((client as any)._mergedHeaders.Authorization).toBe( + "Bearer profile-access-token", + ); + expect((client as any)._mergedHeaders["x-api-key"]).toBeUndefined(); + }); + + it("suppresses profile auth when environment auth is set", () => { + writeProfileConfig({ + profiles: { + default: { + api_key: "profile-key", + api_url: "https://profile.example.com", + workspace_id: "workspace-id", + oauth: { + access_token: "profile-access-token", + }, + }, + }, + }); + process.env.LANGSMITH_API_KEY = "env-key"; + + const client = new Client(); + + expect((client as any).apiUrl).toBe("https://profile.example.com"); + expect((client as any).workspaceId).toBe("workspace-id"); + expect((client as any)._mergedHeaders["x-api-key"]).toBe("env-key"); + expect((client as any)._mergedHeaders.Authorization).toBeUndefined(); + }); + + it("suppresses profile auth when constructor auth is set", async () => { + writeProfileConfig({ + profiles: { + default: { + api_key: "profile-key", + api_url: "https://profile.example.com", + oauth: { + access_token: "old-access-token", + refresh_token: "old-refresh-token", + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }, + }, + }); + const mockFetch = jest.fn( + async () => new Response("{}", { status: 200 }), + ); + const client = new Client({ + apiKey: "constructor-key", + fetchImplementation: mockFetch as any, + }); + + await (client as any)._fetch("https://profile.example.com/info", { + headers: (client as any)._mergedHeaders, + }); + + expect((client as any).apiUrl).toBe("https://profile.example.com"); + expect((client as any)._mergedHeaders["x-api-key"]).toBe( + "constructor-key", + ); + expect((client as any)._mergedHeaders.Authorization).toBeUndefined(); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [[requestUrl]] = mockFetch.mock.calls as unknown as [ + [RequestInfo | URL, RequestInit | undefined], + ]; + expect(String(requestUrl)).toBe("https://profile.example.com/info"); + }); + + it("refreshes expired profile OAuth tokens before requests", async () => { + const configPath = writeProfileConfig({ + profiles: { + default: { + api_url: "https://profile.example.com", + oauth: { + access_token: "old-access-token", + refresh_token: "old-refresh-token", + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }, + }, + }); + const mockFetch = jest.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + // This deployment serves no discovery document, so refresh falls back + // to the configured mount point. + if ( + String(input).includes("/.well-known/oauth-authorization-server") + ) { + return new Response("", { status: 404 }); + } + if (String(input) === "https://profile.example.com/oauth/token") { + expect(init?.method).toBe("POST"); + expect(String(init?.body)).toContain("grant_type=refresh_token"); + expect(String(init?.body)).toContain("client_id=langsmith-cli"); + expect(String(init?.body)).toContain( + "refresh_token=old-refresh-token", + ); + return new Response( + JSON.stringify({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 300, + }), + { status: 200 }, + ); + } + return new Response("{}", { status: 200 }); + }, + ); + const client = new Client({ fetchImplementation: mockFetch as any }); + + await (client as any)._fetch("https://profile.example.com/info", { + headers: (client as any)._mergedHeaders, + }); + + const tokenCalls = mockFetch.mock.calls.filter( + ([input]) => + String(input) === "https://profile.example.com/oauth/token", + ); + expect(tokenCalls).toHaveLength(1); + const infoCall = mockFetch.mock.calls.find( + ([input]) => String(input) === "https://profile.example.com/info", + ); + expect(infoCall).toBeDefined(); + const requestInit = infoCall![1] as RequestInit; + expect(requestInit.headers).toMatchObject({ + Authorization: "Bearer new-access-token", + }); + const updated = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(updated.profiles.default.oauth.access_token).toBe( + "new-access-token", + ); + expect(updated.profiles.default.oauth.refresh_token).toBe( + "new-refresh-token", + ); + expect(updated.profiles.default.oauth).not.toHaveProperty("token_type"); + expect(updated.profiles.default).not.toHaveProperty("bearer_token"); + }); + + it("preserves explicit Authorization headers during profile refresh", async () => { + writeProfileConfig({ + profiles: { + default: { + api_url: "https://profile.example.com", + oauth: { + access_token: "old-access-token", + refresh_token: "old-refresh-token", + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }, + }, + }); + const mockFetch = jest.fn( + async () => new Response("{}", { status: 200 }), + ); + const client = new Client({ fetchImplementation: mockFetch as any }); + + await (client as any)._fetch("https://profile.example.com/info", { + headers: { + Authorization: "Bearer explicit-token", + }, + }); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [[requestUrl, requestInit]] = mockFetch.mock.calls as unknown as [ + [RequestInfo | URL, RequestInit | undefined], + ]; + expect(String(requestUrl)).toBe("https://profile.example.com/info"); + expect(requestInit?.headers).toMatchObject({ + Authorization: "Bearer explicit-token", + }); + }); + + it("refreshes profile OAuth tokens against profile api_url", async () => { + writeProfileConfig({ + profiles: { + default: { + api_url: "https://profile.example.com", + oauth: { + access_token: "old-access-token", + refresh_token: "old-refresh-token", + expires_at: new Date(Date.now() - 60_000).toISOString(), + }, + }, + }, + }); + const mockFetch = jest.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + if ( + String(input).includes("/.well-known/oauth-authorization-server") + ) { + return new Response("", { status: 404 }); + } + if (String(input) === "https://profile.example.com/oauth/token") { + expect(init?.method).toBe("POST"); + return new Response( + JSON.stringify({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 300, + }), + { status: 200 }, + ); + } + if (String(input) === "https://override.example.com/oauth/token") { + return new Response("wrong refresh URL", { status: 418 }); + } + return new Response("{}", { status: 200 }); + }, + ); + const client = new Client({ + apiUrl: "https://override.example.com", + fetchImplementation: mockFetch as any, + }); + + await (client as any)._fetch("https://override.example.com/info", { + headers: (client as any)._mergedHeaders, + }); + + // Discovery probes aside, refresh must target the profile's api_url + // rather than the client-level override. + expect( + mockFetch.mock.calls + .map(([input]) => String(input)) + .filter( + (url) => !url.includes("/.well-known/oauth-authorization-server"), + ), + ).toEqual([ + "https://profile.example.com/oauth/token", + "https://override.example.com/info", + ]); + }); + }); + + describe("getHostUrl", () => { + it("should return the webUrl if it exists", () => { + const client = new Client({ + webUrl: "http://example.com", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("http://example.com"); + }); + + it("should return 'http://localhost:3000' if apiUrl is localhost", () => { + const client = new Client({ apiUrl: "http://localhost/api" }); + const result = (client as any).getHostUrl(); + expect(result).toBe("http://localhost:3000"); + }); + + it("should return the webUrl without '/api' if apiUrl contains '/api'", () => { + const client = new Client({ + webUrl: "https://example.com", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://example.com"); + }); + + it("should trim trailing slash on a passed webUrl", () => { + const client = new Client({ webUrl: "https://example.com/" }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://example.com"); + }); + + it("should trim /api/v1 from the apiUrl", () => { + const client = new Client({ + apiUrl: "https://my-other-domain.com/api/v1", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://my-other-domain.com"); + }); + + it("should return 'https://dev.smith.langchain.com' if apiUrl contains 'dev'", () => { + const client = new Client({ + apiUrl: "https://dev.smith.langchain.com/api", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://dev.smith.langchain.com"); + }); + + it("should return 'https://beta.smith.langchain.com' if apiUrl contains 'beta'", () => { + const client = new Client({ + apiUrl: "https://beta.smith.langchain.com/api", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://beta.smith.langchain.com"); + }); + + it("should return 'https://eu.smith.langchain.com' if apiUrl contains 'eu'", () => { + const client = new Client({ + apiUrl: "https://eu.smith.langchain.com/api", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://eu.smith.langchain.com"); + }); + + it("should return 'https://apac.smith.langchain.com' if apiUrl contains 'apac'", () => { + const client = new Client({ + apiUrl: "https://apac.api.smith.langchain.com/v1", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://apac.smith.langchain.com"); + }); + + it("should return 'https://smith.langchain.com' for any other apiUrl", () => { + const client = new Client({ + apiUrl: "https://smith.langchain.com/api", + apiKey: "test-api-key", + }); + const result = (client as any).getHostUrl(); + expect(result).toBe("https://smith.langchain.com"); + }); + }); + + describe("_getOpenAPIBaseUrl", () => { + const getOpenAPIBaseUrl = (apiUrl: string) => + ( + new Client({ apiUrl, apiKey: "test-api-key" }) as any + )._getOpenAPIBaseUrl(); + + it("should strip a trailing /api/v1", () => { + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com/api/v1")).toBe( + "https://api.smith.langchain.com", + ); + }); + + it("should strip a trailing /api/v1 with a trailing slash", () => { + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com/api/v1/")).toBe( + "https://api.smith.langchain.com", + ); + }); + + it("should not strip a bare trailing /v1", () => { + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com/v1")).toBe( + "https://api.smith.langchain.com/v1", + ); + expect( + getOpenAPIBaseUrl("https://self-hosted.example.com/langsmith/v1"), + ).toBe("https://self-hosted.example.com/langsmith/v1"); + }); + + it("should strip a trailing /api", () => { + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com/api")).toBe( + "https://api.smith.langchain.com", + ); + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com/api/")).toBe( + "https://api.smith.langchain.com", + ); + }); + + it("should preserve a self-hosted path prefix", () => { + expect( + getOpenAPIBaseUrl("https://self-hosted.example.com/langsmith/api/v1"), + ).toBe("https://self-hosted.example.com/langsmith"); + expect( + getOpenAPIBaseUrl("https://self-hosted.example.com/langsmith/api"), + ).toBe("https://self-hosted.example.com/langsmith"); + }); + + it("should leave a URL without an /api suffix unchanged", () => { + expect(getOpenAPIBaseUrl("https://api.smith.langchain.com")).toBe( + "https://api.smith.langchain.com", + ); + expect(getOpenAPIBaseUrl("http://localhost:1984")).toBe( + "http://localhost:1984", + ); + }); + + it("should only strip /api when it is the trailing path segment", () => { + expect( + getOpenAPIBaseUrl("https://api.smith.langchain.com/api/runs"), + ).toBe("https://api.smith.langchain.com/api/runs"); + expect(getOpenAPIBaseUrl("https://api.example.com")).toBe( + "https://api.example.com", + ); + }); + }); + + describe("env functions", () => { + it("should return the env variables correctly", async () => { + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_REVISION_ID = "test_revision_id"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_API_KEY = "fake_api_key"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_OTHER_KEY = "test_other_key"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_OTHER_NON_SENSITIVE_METADATA = "test_some_metadata"; + // eslint-disable-next-line no-process-env + process.env.LANGCHAIN_ENDPOINT = "https://example.com"; + // eslint-disable-next-line no-process-env + process.env.SOME_RANDOM_THING = "random"; + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_ADMIN_PASSWORD = "hunter2hunter2"; + + const envVars = getLangSmithEnvironmentVariables(); + const langchainMetadataEnvVars = getLangSmithEnvVarsMetadata(); + + expect(envVars).toMatchObject({ + LANGCHAIN_REVISION_ID: "test_revision_id", + LANGCHAIN_API_KEY: "fa********ey", + LANGCHAIN_OTHER_KEY: "te**********ey", + LANGCHAIN_ENDPOINT: "https://example.com", + LANGCHAIN_OTHER_NON_SENSITIVE_METADATA: "test_some_metadata", + LANGSMITH_ADMIN_PASSWORD: "hu**********r2", + }); + expect(envVars).not.toHaveProperty("SOME_RANDOM_THING"); + + delete langchainMetadataEnvVars.LANGSMITH_TRACING; + expect(langchainMetadataEnvVars).toEqual({ + revision_id: "test_revision_id", + LANGCHAIN_OTHER_NON_SENSITIVE_METADATA: "test_some_metadata", + }); + }); + }); + + describe("parseHubIdentifier", () => { + it("should parse valid identifiers correctly", () => { + expect(parseHubIdentifier("name")).toEqual(["-", "name", "latest"]); + expect(parseHubIdentifier("owner/name")).toEqual([ + "owner", + "name", + "latest", + ]); + expect(parseHubIdentifier("owner/name:commit")).toEqual([ + "owner", + "name", + "commit", + ]); + expect(parseHubIdentifier("name:commit")).toEqual([ + "-", + "name", + "commit", + ]); + }); + + it("should throw an error for invalid identifiers", () => { + const invalidIdentifiers = [ + "", + "/", + ":", + "owner/", + "/name", + "owner//name", + "owner/name/", + "owner/name/extra", + ":commit", + ]; + + invalidIdentifiers.forEach((identifier) => { + expect(() => parseHubIdentifier(identifier)).toThrowError( + /Invalid prompt identifier format/, + ); + }); + }); + }); + + describe("pullPromptCommit", () => { + it("requires dangerouslyPullPublicPrompt for public prompt identifiers", async () => { + const client = new Client({ apiKey: "test-api-key" }); + const fetchSpy = jest.spyOn(client as any, "_fetchPromptFromApi"); + + await expect( + client.pullPromptCommit("someuser/someprompt"), + ).rejects.toThrow(/dangerouslyPullPublicPrompt: true/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("allows public prompt identifiers with dangerouslyPullPublicPrompt", async () => { + const client = new Client({ apiKey: "test-api-key" }); + const promptCommit = { + owner: "someuser", + repo: "someprompt", + commit_hash: "abc123", + manifest: {}, + examples: [], + }; + jest + .spyOn(client as any, "_fetchPromptFromApi") + .mockResolvedValue(promptCommit); + + await expect( + client.pullPromptCommit("someuser/someprompt", { + dangerouslyPullPublicPrompt: true, + }), + ).resolves.toEqual(promptCommit); + }); + }); + + describe("listCommits", () => { + it("should handle private prompts without explicit owner", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + // Mock the _getPaginated method to capture the URL being called + let capturedUrl: string | undefined; + jest + .spyOn(client as any, "_getPaginated") + .mockImplementation(async function* (...args: any[]) { + capturedUrl = args[0]; + yield []; + }); + + // Call listCommits with just the prompt name (no owner) + const commits = []; + for await (const commit of client.listCommits("my-prompt")) { + commits.push(commit); + } + + // Verify that the URL uses "-" as the owner for private prompts + expect(capturedUrl).toBe("/commits/-/my-prompt/"); + }); + + it("should handle prompts with explicit owner", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + let capturedUrl: string | undefined; + jest + .spyOn(client as any, "_getPaginated") + .mockImplementation(async function* (...args: any[]) { + capturedUrl = args[0]; + yield []; + }); + + // Call listCommits with owner/name format + const commits = []; + for await (const commit of client.listCommits("owner/my-prompt")) { + commits.push(commit); + } + + // Verify that the URL uses the provided owner + expect(capturedUrl).toBe("/commits/owner/my-prompt/"); + }); + + it("should handle prompts with commit specifier", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + let capturedUrl: string | undefined; + jest + .spyOn(client as any, "_getPaginated") + .mockImplementation(async function* (...args: any[]) { + capturedUrl = args[0]; + yield []; + }); + + // Call listCommits with prompt:commit format (commit part should be ignored for listCommits) + const commits = []; + for await (const commit of client.listCommits("my-prompt:abc123")) { + commits.push(commit); + } + + // The commit identifier is parsed but not used in the URL (listCommits lists all commits) + expect(capturedUrl).toBe("/commits/-/my-prompt/"); + }); + }); + + describe("createCommit", () => { + it("should include description in request body when provided", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + jest.spyOn(client as any, "promptExists").mockResolvedValue(true); + jest + .spyOn(client as any, "_getLatestCommitHash") + .mockResolvedValue("parent123"); + jest.spyOn(client as any, "_fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ commit_hash: "new123", id: "1" }), + text: async () => "", + headers: new Headers(), + }); + jest + .spyOn(client as any, "_getPromptUrl") + .mockReturnValue("https://smith.langchain.com/prompts/test"); + + // Capture the fetch call body + const fetchSpy = jest.spyOn(client as any, "_fetch"); + + await client.createCommit( + "owner/my-prompt", + { id: "test" }, + { + description: "initial prompt version", + }, + ); + + const fetchCall = fetchSpy.mock.calls[0]; + const capturedBody = (fetchCall[1] as Record) + ?.body as string; + const parsed = JSON.parse(capturedBody); + expect(parsed.description).toBe("initial prompt version"); + }); + + it("should omit description from request body when not provided", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + jest.spyOn(client as any, "promptExists").mockResolvedValue(true); + jest + .spyOn(client as any, "_getLatestCommitHash") + .mockResolvedValue("parent123"); + jest.spyOn(client as any, "_fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ commit_hash: "new123", id: "1" }), + text: async () => "", + headers: new Headers(), + }); + jest + .spyOn(client as any, "_getPromptUrl") + .mockReturnValue("https://smith.langchain.com/prompts/test"); + + const fetchSpy = jest.spyOn(client as any, "_fetch"); + + await client.createCommit("owner/my-prompt", { id: "test" }); + + const fetchCall = fetchSpy.mock.calls[0]; + const capturedBody = (fetchCall[1] as Record) + ?.body as string; + const parsed = JSON.parse(capturedBody); + expect(parsed.description).toBeUndefined(); + }); + + it("should create commit tags when provided", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + jest.spyOn(client as any, "promptExists").mockResolvedValue(true); + jest + .spyOn(client as any, "_getLatestCommitHash") + .mockResolvedValue("parent123"); + jest.spyOn(client as any, "_fetch").mockImplementation(async (url) => ({ + ok: true, + status: 200, + json: async () => + String(url).includes("/commits/") + ? { commit: { commit_hash: "new123", id: "commit-id" } } + : {}, + text: async () => "", + headers: new Headers(), + })); + jest + .spyOn(client as any, "_getPromptUrl") + .mockReturnValue("https://smith.langchain.com/prompts/test"); + + const fetchSpy = jest.spyOn(client as any, "_fetch"); + + await client.createCommit( + "owner/my-prompt", + { id: "test" }, + { + tags: ["production", "v1"], + }, + ); + + const tagCalls = fetchSpy.mock.calls.filter(([url]) => + String(url).endsWith("/repos/owner/my-prompt/tags"), + ); + expect(tagCalls).toHaveLength(2); + expect( + tagCalls.map(([, init]) => + JSON.parse((init as RequestInit).body as string), + ), + ).toEqual( + expect.arrayContaining([ + { tag_name: "production", commit_id: "commit-id" }, + { tag_name: "v1", commit_id: "commit-id" }, + ]), + ); + }); + }); + + describe("pushPrompt", () => { + it("should forward commit tags without updating prompt metadata", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + jest.spyOn(client, "promptExists").mockResolvedValue(true); + const updatePromptSpy = jest.spyOn(client, "updatePrompt"); + const createCommitSpy = jest + .spyOn(client, "createCommit") + .mockResolvedValue("https://smith.langchain.com/prompts/test"); + + await client.pushPrompt("owner/my-prompt", { + object: { id: "test" }, + commitTags: ["production"], + }); + + expect(updatePromptSpy).not.toHaveBeenCalled(); + expect(createCommitSpy).toHaveBeenCalledWith( + "owner/my-prompt", + { id: "test" }, + { + parentCommitHash: undefined, + tags: ["production"], + description: undefined, + }, + ); + }); + }); + + describe("update sampling", () => { + // A child whose own id would be filtered out if hashed on its own. + const childId = "00000000-0000-0000-0000-000000000004"; + const sampledRoot = "00000000-0000-0000-0000-000000000015"; + const filteredRoot = "00000000-0000-0000-0000-000000000004"; + const dotted = (root: string) => + `20240101T000000000000Z${root}.20240101T000000000001Z${childId}`; + + const updateWith = (dottedOrder: string) => { + const client = new Client({ + apiKey: "test-api-key", + tracingSamplingRate: 0.5, + }); + const filtered = (client as any)._filterForSampling([ + { id: childId, dotted_order: dottedOrder, outputs: { a: 1 } }, + ]); + return filtered.length; + }; + + it("recovers the trace from dotted_order when trace_id is absent", () => { + // Root sampled in -> the child's patch goes out with it. + expect(updateWith(dotted(sampledRoot))).toBe(1); + // Root filtered out -> no orphan patch. + expect(updateWith(dotted(filteredRoot))).toBe(0); + }); + + it("still falls back to the run id with neither trace_id nor dotted order", () => { + const client = new Client({ + apiKey: "test-api-key", + tracingSamplingRate: 0.5, + }); + expect( + (client as any)._filterForSampling([{ id: sampledRoot }]), + ).toHaveLength(1); + expect( + (client as any)._filterForSampling([{ id: filteredRoot }]), + ).toHaveLength(0); + }); + }); + + describe("cross-SDK sampling agreement", () => { + // Golden decisions at rate 0.5. The Python SDK asserts this exact table in + // python/tests/unit_tests/test_client.py: both must agree, or a trace + // sampled in by one SDK is dropped by the other. Regenerate both sides + // together, never one. + const decisionsAtHalf: [string, boolean][] = [ + ["00000000-0000-0000-0000-000000000001", false], + ["00000000-0000-0000-0000-000000000004", false], + ["00000000-0000-0000-0000-000000000015", true], + ["0198f8a0-1234-7000-8000-000000000042", false], + ["b3d2c1a0-5f6e-7d8c-9b0a-1e2f3d4c5b6a", false], + ["abc", true], + ["", false], + ["trace-1", true], + ["trace-2", false], + // Non-ASCII pins the UTF-8 encoding: a UTF-16 implementation diverges. + ["\u00fcn\u00efc\u00f8d\u00e9-trace", false], + ["\u65e5\u672c\u8a9e", false], + // Case folding happens before hashing. + ["MiXeD-CaSe-ID", true], + ]; + + it.each(decisionsAtHalf)( + "matches the Python SDK for %p", + (identifier, expected) => { + expect(isSampledById(identifier, 0.5)).toBe(expected); + }, + ); + + it("skips hashing at the rate bounds", () => { + for (const identifier of ["anything", "", null, undefined]) { + expect(isSampledById(identifier, undefined)).toBe(true); + expect(isSampledById(identifier, 1)).toBe(true); + expect(isSampledById(identifier, 0)).toBe(false); + } + }); + + it("honours the rate across many ids", () => { + const ids = Array.from( + { length: 20_000 }, + (_, i) => `0198f8a0-1234-7000-8000-${String(i).padStart(12, "0")}`, + ); + for (const rate of [0.1, 0.25, 0.75]) { + const kept = + ids.filter((id) => isSampledById(id, rate)).length / ids.length; + expect(Math.abs(kept - rate)).toBeLessThan(0.02); + } + }); + }); + + describe("_filterForSampling run ID logic", () => { + // Buckets under XXH3-128 % 1_000_000: 10679 and 982794, so these stay on + // opposite sides of the threshold for any rate between 0.02 and 0.98. + const sampledRunId = "00000000-0000-0000-0000-000000000015"; + const filteredRunId = "00000000-0000-0000-0000-000000000004"; + + const run = (id: string, traceId = id) => ({ + id, + trace_id: traceId, + name: `run-${id}`, + run_type: "llm" as const, + inputs: { text: id }, + }); + + it("should filter creates and patches by stable run ID", () => { + const client = new Client({ + apiKey: "test-api-key", + tracingSamplingRate: 0.5, + }); + + const sampledCreate = run(sampledRunId); + const filteredCreate = run(filteredRunId); + const sampledUpdate = { + ...sampledCreate, + outputs: { result: "sampled" }, + }; + const filteredUpdate = { + ...filteredCreate, + outputs: { result: "filtered" }, + }; + + expect( + (client as any)._filterForSampling([sampledCreate, filteredCreate]), + ).toEqual([sampledCreate]); + expect( + (client as any)._filterForSampling([sampledUpdate, filteredUpdate]), + ).toEqual([sampledUpdate]); + expect((client as any)._shouldSample(sampledRunId)).toBe(true); + expect((client as any)._shouldSample(filteredRunId)).toBe(false); + }); + + it("should follow trace ID rather than run ID", () => { + const client = new Client({ + apiKey: "test-api-key", + tracingSamplingRate: 0.5, + }); + + // Child's own id would be filtered, but its trace is sampled -> kept. + const childInSampledTrace = run(filteredRunId, sampledRunId); + // Child's own id would be sampled, but its trace is filtered -> dropped. + const childInFilteredTrace = run(sampledRunId, filteredRunId); + + expect( + (client as any)._filterForSampling([ + childInSampledTrace, + childInFilteredTrace, + ]), + ).toEqual([childInSampledTrace]); + }); + }); + + describe("Workspace Support", () => { + // eslint-disable-next-line no-process-env + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + // eslint-disable-next-line no-process-env + process.env = { ...originalEnv }; + }); + + afterEach(() => { + // eslint-disable-next-line no-process-env + process.env = originalEnv; + }); + + it("should read workspaceId from environment variable", () => { + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_WORKSPACE_ID = "env-workspace-id"; + const client = new Client(); + expect((client as any).workspaceId).toBe("env-workspace-id"); + }); + + it("should prioritize config over environment variable", () => { + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_WORKSPACE_ID = "env-workspace-id"; + const client = new Client({ workspaceId: "config-workspace-id" }); + expect((client as any).workspaceId).toBe("config-workspace-id"); + }); + + describe("E2E Workspace Tests", () => { + it("should include workspace ID in headers when making API calls", async () => { + // set env vars + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_API_KEY = "test-api-key"; + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_WORKSPACE_ID = "test-workspace-id"; + + // Create mock fetch function + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id: "run-123", name: "test-run" }), + text: async () => '{"id":"run-123","name":"test-run"}', + })); + + const client = new Client({ + fetchImplementation: mockFetch as any, + }); + + // API call + await client.createRun({ + name: "test-run", + run_type: "llm", + inputs: { text: "hello" }, + }); + + // Verify the call was made with correct headers + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/runs"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-tenant-id": "test-workspace-id", + "x-api-key": "test-api-key", + }), + }), + ); + + // eslint-disable-next-line no-process-env + delete process.env.LANGSMITH_API_KEY; + // eslint-disable-next-line no-process-env + delete process.env.LANGSMITH_WORKSPACE_ID; + }); + + it("should handle org-scoped key error and throw workspace validation error", async () => { + // Create mock fetch function that returns 403 with org-scoped error + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: false, + status: 403, + statusText: "Forbidden", + json: async () => ({ error: "org_scoped_key_requires_workspace" }), + text: async () => '{"error":"org_scoped_key_requires_workspace"}', + })); + + const client = new Client({ + apiKey: "org-scoped-key", + fetchImplementation: mockFetch as any, + }); + + // call API without workspace - should fail with workspace validation error + await expect( + client.createRun({ + name: "test-run", + run_type: "llm", + inputs: { text: "hello" }, + }), + ).rejects.toThrow("[403]: Forbidden"); + + expect(mockFetch).toHaveBeenCalled(); + }); + + it("should handle other 403 errors without throwing workspace validation error", async () => { + // Create mock fetch function that returns 403 with different error + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: false, + status: 403, + statusText: "Forbidden", + json: async () => ({ error: "insufficient_permissions" }), + text: async () => '{"error":"insufficient_permissions"}', + })); + + const client = new Client({ + apiKey: "test-key", + fetchImplementation: mockFetch as any, + }); + + // call API - should fail with regular error + await expect( + client.createRun({ + name: "test-run", + run_type: "llm", + inputs: { text: "hello" }, + }), + ).rejects.toThrow("Failed to create run"); + + expect(mockFetch).toHaveBeenCalled(); + }); + + it("should work correctly when workspace is provided in options", async () => { + // Create mock fetch function + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id: "run-123", name: "test-run" }), + text: async () => '{"id":"run-123","name":"test-run"}', + })); + + const client = new Client({ + apiKey: "org-scoped-key", + fetchImplementation: mockFetch as any, + }); + + // call with workspace ID in options should succeed + await client.createRun( + { + name: "test-run", + run_type: "llm", + inputs: { text: "hello" }, + }, + { workspaceId: "test-workspace-id" }, + ); + + // check call was made with correct headers + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining("/runs"), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "x-tenant-id": "test-workspace-id", + }), + }), + ); + }); + + it("should handle multiple API calls with different workspace configurations", async () => { + // Create mock fetch function + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ id: "run-123", name: "test-run" }), + text: async () => '{"id":"run-123","name":"test-run"}', + })); + + const client = new Client({ + apiKey: "test-api-key", + workspaceId: "default-workspace-id", + fetchImplementation: mockFetch as any, + }); + + // first call uses default workspace + await client.createRun({ + name: "test-run", + run_type: "llm", + inputs: { text: "hello" }, + }); + + // second call overrides workspace + await client.updateRun( + "550e8400-e29b-41d4-a716-446655440000", + { + outputs: { result: "updated" }, + }, + { workspaceId: "override-workspace-id" }, + ); + + expect(mockFetch).toHaveBeenCalledTimes(2); + + const firstCall = mockFetch.mock.calls[0]; + const secondCall = mockFetch.mock.calls[1]; + + expect((firstCall[1] as any).headers["x-tenant-id"]).toBe( + "default-workspace-id", + ); + expect((secondCall[1] as any).headers["x-tenant-id"]).toBe( + "override-workspace-id", + ); + }); + }); + }); + + describe("listRuns", () => { + it("should warn when child_run_ids is in select parameter", async () => { + const consoleWarnSpy = jest + .spyOn(console, "warn") + .mockImplementation(() => {}); + + // Create mock fetch function + const mockFetch = jest.fn().mockImplementation(async () => ({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ runs: [] }), + })); + + const client = new Client({ + apiKey: "org-scoped-key", + fetchImplementation: mockFetch as any, + }); + + // Test that warning is issued when child_run_ids is in select + const runs = []; + for await (const run of client.listRuns({ + projectId: "00000000-0000-0000-0000-000000000000", + select: ["id", "name", "child_run_ids"], + })) { + runs.push(run); + } + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching( + "Deprecated: 'child_run_ids' in the listRuns select parameter is deprecated and will be removed in a future version.", + ), + ); + consoleWarnSpy.mockClear(); + + // Test that no warning is issued when child_run_ids is not in select + for await (const run of client.listRuns({ + projectId: "00000000-0000-0000-0000-000000000000", + select: ["id", "name"], + })) { + runs.push(run); + } + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + }); + + describe("omitTracedRuntimeInfo", () => { + it("should omit runtime info when flag is true", () => { + const run: any = { + id: "test-run-id", + name: "test-run", + run_type: "llm" as const, + inputs: { text: "hello" }, + }; + + const result = mergeRuntimeEnvIntoRun(run, undefined, true); + + expect(result.extra).toBeUndefined(); + }); + + it("should include runtime info when flag is false", () => { + const run: any = { + id: "test-run-id", + name: "test-run", + run_type: "llm" as const, + inputs: { text: "hello" }, + }; + + const result = mergeRuntimeEnvIntoRun(run, undefined, false); + + expect(result.extra).toBeDefined(); + expect(result.extra.runtime).toBeDefined(); + expect(result.extra.metadata).toBeDefined(); + }); + }); + + describe("tracingSamplingRate is reported on runs", () => { + const metadataOf = (run: RunCreate, rate?: number) => + mergeRuntimeEnvIntoRun(run, undefined, false, rate).extra?.metadata as + | Record + | undefined; + + const run = (): RunCreate => ({ + id: uuid(), + name: "test-run", + run_type: "llm", + inputs: {}, + }); + + // Sampled in at rate 0.5 per the cross-SDK golden table above, so the + // client-level cases below survive `_filterForSampling`. + const sampledId = "00000000-0000-0000-0000-000000000015"; + + // Body of the one request the mock fetch saw with this method. + const sentBody = (callSpy: any, method: string): KVMap => { + const calls = callSpy.mock.calls.filter( + (call: unknown[]) => + (call[call.length - 1] as { method?: string })?.method === method, + ); + expect(calls.length).toBe(1); + const { body } = calls[0][calls[0].length - 1] as { + body: string | Uint8Array; + }; + return JSON.parse( + typeof body === "string" ? body : new TextDecoder().decode(body), + ); + }; + + it("should report the rate on a created run", () => { + expect(metadataOf(run(), 0.25)?.ls_tracing_sample_rate).toBe(0.25); + }); + + it("should omit the key when no rate is configured", () => { + // Absent, not null: a consumer reads absence as "no sampling". + expect(metadataOf(run(), undefined)).not.toHaveProperty( + "ls_tracing_sample_rate", + ); + }); + + it("should let the client's own rate beat one already on the run", () => { + // `RunTree.createChild` copies the parent's metadata onto every child, so + // a run can arrive carrying the rate of a differently-configured client. + // Reporting that one would make the extrapolation wrong, not just absent. + const withOwnRate: RunCreate = { + ...run(), + extra: { metadata: { ls_tracing_sample_rate: 0.9, user: "x" } }, + }; + const metadata = metadataOf(withOwnRate, 0.25); + + expect(metadata?.ls_tracing_sample_rate).toBe(0.25); + expect(metadata?.user).toBe("x"); + }); + + it("should wire the client's configured rate into a posted run", async () => { + // The helper above is pure; this pins the client actually feeding it + // `this.tracingSampleRate`, which no unit on the helper can catch. + const { client, callSpy } = mockClient({ tracingSamplingRate: 0.5 }); + await client.createRun({ + id: sampledId, + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + }); + + expect( + sentBody(callSpy, "POST").extra.metadata.ls_tracing_sample_rate, + ).toBe(0.5); + }); + + it("should re-stamp the rate on a non-batched patch carrying extra", async () => { + // The server replaces `extra` wholesale on update, so a patch that brings + // its own would drop what the create stamped. + const { client, callSpy } = mockClient({ tracingSamplingRate: 0.5 }); + await client.updateRun(sampledId, { + end_time: Date.now(), + extra: { metadata: { user: "x" } }, + }); + + const metadata = sentBody(callSpy, "PATCH").extra.metadata; + expect(metadata.ls_tracing_sample_rate).toBe(0.5); + expect(metadata.user).toBe("x"); + }); + + it("should not introduce extra on a patch that omits it", async () => { + // Sending one here would replace the create's `extra` with a stub and + // erase every metadata key the caller set at create time. + const { client, callSpy } = mockClient({ tracingSamplingRate: 0.5 }); + await client.updateRun(sampledId, { end_time: Date.now() }); + + expect(sentBody(callSpy, "PATCH")).not.toHaveProperty("extra"); + }); + }); + + describe("hideMetadata covers runtime env metadata", () => { + const postedMetadata = async ( + hideMetadata: ClientConfig["hideMetadata"], + ) => { + // Read once at construction, so it has to be set before the client exists. + process.env.LANGCHAIN_TEST_ENV_METADATA = "env-value"; + try { + const { client, callSpy } = mockClient({ hideMetadata }); + await client.createRun({ + id: uuid(), + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + extra: { metadata: { random: 123 } }, + }); + const { data } = await getAssumedTreeFromCalls( + callSpy.mock.calls, + client, + ); + return data["traced:0"].extra?.metadata; + } finally { + delete process.env.LANGCHAIN_TEST_ENV_METADATA; + } + }; + + it("should hide metadata merged in after masking", async () => { + expect(await postedMetadata(true)).toEqual({}); + }); + + it("should pass env metadata to a hideMetadata function", async () => { + const metadata = await postedMetadata((metadata) => + Object.fromEntries( + Object.entries(metadata).filter( + ([key]) => !key.startsWith("LANGCHAIN_"), + ), + ), + ); + + expect(metadata).not.toHaveProperty("LANGCHAIN_TEST_ENV_METADATA"); + expect(metadata?.random).toBe(123); + }); + + // The OTEL exporter consumes queued runs directly, so it needs the same + // re-masking the ingest endpoints get from prepareRunCreateOrUpdateInputs. + it("should hide metadata merged in before OTEL export", async () => { + process.env.LANGCHAIN_TEST_ENV_METADATA = "env-value"; + try { + const client = new Client({ apiKey: "MOCK", hideMetadata: true }); + const exportBatch = jest.fn(); + (client as any).langSmithToOTELTranslator = { exportBatch }; + + // What processRunOperation queues: masked metadata, then env merged in. + const item = mergeRuntimeEnvIntoRun({ + id: uuid(), + name: "traced", + run_type: "llm", + inputs: { in: "put" }, + extra: { metadata: {} }, + } as any); + + await (client as any)._processBatch([ + { action: "create", item, otelContext: {} }, + ]); + + const [operations] = exportBatch.mock.calls[0] as [any[]]; + expect(operations[0].run.extra.metadata).toEqual({}); + } finally { + delete process.env.LANGCHAIN_TEST_ENV_METADATA; + } + }); + }); + + describe("listRuns normalizes naive timestamps", () => { + it("should append Z to naive timestamps returned by the API", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + const naiveRun = { + id: "run-1", + name: "test-run", + run_type: "chain", + inputs: {}, + start_time: "2026-03-12T19:38:10.269893", + end_time: "2026-03-12T19:38:11.000000", + session_id: "proj-1", + trace_id: "run-1", + dotted_order: "20260312T193810269893Zrun-1", + }; + + const mockResponse = { + runs: [naiveRun], + cursors: {}, + }; + + jest.spyOn(client as any, "_fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse, + text: async () => JSON.stringify(mockResponse), + }); + + const runs: any[] = []; + for await (const run of client.listRuns({ projectId: "proj-1" })) { + runs.push(run); + } + + expect(runs).toHaveLength(1); + expect(runs[0].start_time).toBe("2026-03-12T19:38:10.269893Z"); + expect(runs[0].end_time).toBe("2026-03-12T19:38:11.000000Z"); + }); + + it("should not double-append Z to already-aware timestamps", async () => { + const client = new Client({ apiKey: "test-api-key" }); + + const awareRun = { + id: "run-2", + name: "test-run", + run_type: "chain", + inputs: {}, + start_time: "2026-03-12T19:38:10.269893Z", + end_time: "2026-03-12T19:38:11.000000+00:00", + session_id: "proj-1", + trace_id: "run-2", + dotted_order: "20260312T193810269893Zrun-2", + }; + + const mockResponse = { + runs: [awareRun], + cursors: {}, + }; + + jest.spyOn(client as any, "_fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => mockResponse, + text: async () => JSON.stringify(mockResponse), + }); + + const runs: any[] = []; + for await (const run of client.listRuns({ projectId: "proj-1" })) { + runs.push(run); + } + + expect(runs).toHaveLength(1); + expect(runs[0].start_time).toBe("2026-03-12T19:38:10.269893Z"); + expect(runs[0].end_time).toBe("2026-03-12T19:38:11.000000+00:00"); + }); + }); + + describe("custom headers", () => { + it("should include custom headers in requests", () => { + const client = new Client({ + apiKey: "test-api-key", + headers: { + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + }, + }); + + const mergedHeaders = (client as any)._mergedHeaders; + expect(mergedHeaders["X-Custom-Header"]).toBe("custom-value"); + expect(mergedHeaders["X-Another-Header"]).toBe("another-value"); + // Default headers should still be present + expect(mergedHeaders["User-Agent"]).toBeDefined(); + expect(mergedHeaders["x-api-key"]).toBe("test-api-key"); + }); + + it("should not allow custom headers to override required headers", () => { + const client = new Client({ + apiKey: "correct-api-key", + headers: { + "x-api-key": "wrong-key", + "X-Custom-Header": "custom-value", + }, + }); + + const mergedHeaders = (client as any)._mergedHeaders; + // API key from config should take precedence + expect(mergedHeaders["x-api-key"]).toBe("correct-api-key"); + // Custom header should still be present + expect(mergedHeaders["X-Custom-Header"]).toBe("custom-value"); + }); + + it("should allow dynamic update of custom headers", () => { + const client = new Client({ + apiKey: "test-api-key", + headers: { + "X-Initial-Header": "initial-value", + }, + }); + + let mergedHeaders = (client as any)._mergedHeaders; + expect(mergedHeaders["X-Initial-Header"]).toBe("initial-value"); + expect(mergedHeaders["X-New-Header"]).toBeUndefined(); + + // Update custom headers + client.headers = { + "X-New-Header": "new-value", + "X-Another-Header": "another-value", + }; + + mergedHeaders = (client as any)._mergedHeaders; + expect(mergedHeaders["X-Initial-Header"]).toBeUndefined(); + expect(mergedHeaders["X-New-Header"]).toBe("new-value"); + expect(mergedHeaders["X-Another-Header"]).toBe("another-value"); + }); + + it("should return custom headers via getter", () => { + const customHeaders = { "X-Custom-Header": "custom-value" }; + const client = new Client({ + apiKey: "test-api-key", + headers: customHeaders, + }); + + expect(client.headers).toEqual(customHeaders); + }); + + it("should work without custom headers", () => { + const client = new Client({ apiKey: "test-api-key" }); + + const mergedHeaders = (client as any)._mergedHeaders; + expect(mergedHeaders["User-Agent"]).toBeDefined(); + expect(mergedHeaders["x-api-key"]).toBe("test-api-key"); + }); + }); + + describe("_filterNewTokenEvents", () => { + it("should strip kwargs from new_token events", () => { + const client = new Client({ apiKey: "test-api-key" }); + const events = [ + { + name: "new_token", + kwargs: { token: "sensitive streaming data" }, + time: "2024-01-01T00:00:00Z", + }, + { + name: "other_event", + kwargs: { data: "keep this" }, + time: "2024-01-01T00:00:01Z", + }, + ]; + + const filtered = (client as any)._filterNewTokenEvents(events); + + expect(filtered[0].name).toBe("new_token"); + expect(filtered[0].time).toBe("2024-01-01T00:00:00Z"); + expect(filtered[0].kwargs).toBeUndefined(); + expect(filtered[1].kwargs).toEqual({ data: "keep this" }); + }); + + it("should handle empty events array", () => { + const client = new Client({ apiKey: "test-api-key" }); + const filtered = (client as any)._filterNewTokenEvents([]); + expect(filtered).toEqual([]); + }); + + it("should handle undefined events", () => { + const client = new Client({ apiKey: "test-api-key" }); + const filtered = (client as any)._filterNewTokenEvents(undefined); + expect(filtered).toBeUndefined(); + }); + + it("should handle events without kwargs", () => { + const client = new Client({ apiKey: "test-api-key" }); + const events = [ + { name: "new_token", time: "2024-01-01T00:00:00Z" }, + { name: "other_event", time: "2024-01-01T00:00:01Z" }, + ]; + + const filtered = (client as any)._filterNewTokenEvents(events); + + expect(filtered[0]).toEqual({ + name: "new_token", + time: "2024-01-01T00:00:00Z", + }); + expect(filtered[1]).toEqual({ + name: "other_event", + time: "2024-01-01T00:00:01Z", + }); + }); + + it("should preserve other event properties", () => { + const client = new Client({ apiKey: "test-api-key" }); + const events = [ + { + name: "new_token", + kwargs: { token: "data" }, + time: "2024-01-01T00:00:00Z", + message: "token received", + custom_field: "custom_value", + }, + ]; + + const filtered = (client as any)._filterNewTokenEvents(events); + + expect(filtered[0].name).toBe("new_token"); + expect(filtered[0].time).toBe("2024-01-01T00:00:00Z"); + expect(filtered[0].message).toBe("token received"); + expect(filtered[0].custom_field).toBe("custom_value"); + expect(filtered[0].kwargs).toBeUndefined(); + }); + + it("should filter multiple new_token events", () => { + const client = new Client({ apiKey: "test-api-key" }); + const events = [ + { name: "new_token", kwargs: { token: "chunk1" }, time: "t1" }, + { name: "new_token", kwargs: { token: "chunk2" }, time: "t2" }, + { name: "new_token", kwargs: { token: "chunk3" }, time: "t3" }, + ]; + + const filtered = (client as any)._filterNewTokenEvents(events); + + expect(filtered).toHaveLength(3); + filtered.forEach((event: any) => { + expect(event.kwargs).toBeUndefined(); + expect(event.name).toBe("new_token"); + }); + }); + }); + + describe("toString", () => { + it("should not expose sensitive information like API keys", () => { + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + apiKey: "super-secret-api-key-12345", + }); + + const str = client.toString(); + // Ensure API key is NOT in the string representation + expect(str).not.toContain("super-secret-api-key-12345"); + // Ensure the string shows the API URL + expect(str).toContain("https://api.smith.langchain.com"); + // Ensure it's properly formatted + expect(str).toBe( + '[LangSmithClient apiUrl="https://api.smith.langchain.com"]', + ); + }); + + it("should be called when converting to string", () => { + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + apiKey: "secret-key", + }); + + const str = String(client); + expect(str).not.toContain("secret-key"); + expect(str).toContain("https://api.smith.langchain.com"); + }); + + it("should be called by Node.js inspect", () => { + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + apiKey: "secret-key", + }); + + const inspectResult = inspect(client); + expect(inspectResult).not.toContain("secret-key"); + expect(inspectResult).toContain("https://api.smith.langchain.com"); + expect(inspectResult).toBe( + '[LangSmithClient apiUrl="https://api.smith.langchain.com"]', + ); + }); + + it("should expose the Node.js custom inspect hook", () => { + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + apiKey: "secret-key", + }); + + const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + const inspectFn = (client as any)[inspectSymbol]; + expect(typeof inspectFn).toBe("function"); + expect(inspectFn.call(client)).toBe( + '[LangSmithClient apiUrl="https://api.smith.langchain.com"]', + ); + }); + }); +}); + +describe("_checkBackendVersion", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it.each([ + ["0.15.9", true], + ["0.15.4rc1", true], + ["0.16.0", false], + ["0.16.1", false], + ["0.16.4rc1", false], + ["0.17.0", false], + ["", false], + ["not-a-version", true], + ])("version %s -> warns: %s", (version, expectWarn) => { + _checkBackendVersion(version as string, "0.16.0"); + if (expectWarn) { + expect(warnSpy).toHaveBeenCalledTimes(1); + } else { + expect(warnSpy).not.toHaveBeenCalled(); + } + }); + + it.each([ + ["0.15.9", true], + ["0.16.0", false], + ])( + "resource getter checks the backend version (%s -> warns: %s)", + async (version, expectWarn) => { + // Guards the min version passed by the getters: drop the call, or declare + // a version below 0.16.0, and this fails. + const mockFetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ version }), { + status: 200, + statusText: "OK", + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + apiUrl: "http://localhost:8080", + apiKey: "test-api-key", + fetchImplementation: mockFetch, + }); + + void client.runs; + // The check is fire-and-forget: await the same /info promise it awaits, + // then let its callback run. + await ( + client as unknown as { _ensureServerInfo(): Promise } + )._ensureServerInfo(); + await new Promise((resolve) => setImmediate(resolve)); + + if (expectWarn) { + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining("older than the minimum version"), + ); + } else { + expect(warnSpy).not.toHaveBeenCalled(); + } + }, + ); + + it("reports both versions and links to the migration docs", () => { + _checkBackendVersion("0.15.0", "0.16.0"); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Backend version "0.15.0" is older than the minimum version required by this SDK ("0.16.0")', + ), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "https://docs.langchain.com/langsmith/smithdb-sdk-migration", + ), + ); + }); +}); + +describe("getRunUrl deprecation", () => { + beforeEach(() => _resetWarnedMessages()); + + it("warns and does not emit a nested readRun warning", async () => { + const client = new Client({ + apiKey: "test-key", + apiUrl: "https://api.smith.langchain.com", + }); + const runId = "00000000-0000-4000-8000-000000000001"; + const traceId = "00000000-0000-4000-8000-000000000002"; + const sessionId = "00000000-0000-4000-8000-000000000003"; + + const warnings: string[] = []; + const emitWarningSpy = jest + .spyOn(process, "emitWarning") + .mockImplementation((message) => { + warnings.push(String(message)); + }); + jest + .spyOn(client as any, "_getTenantId") + .mockResolvedValue("00000000-0000-4000-8000-000000000004" as never); + + try { + const url = await client.getRunUrl({ + run: { + id: runId, + trace_id: traceId, + session_id: sessionId, + } as any, + }); + expect(url).toContain(runId); + expect( + warnings.filter((w) => w.includes("getRunUrl() is deprecated")), + ).toHaveLength(1); + expect(warnings.filter((w) => w.includes("readRun()"))).toHaveLength(0); + } finally { + emitWarningSpy.mockRestore(); + } + }); +}); + +describe("nested deprecation warnings", () => { + const RUN_ID = "00000000-0000-4000-8000-000000000011"; + const PROJECT_ID = "00000000-0000-4000-8000-000000000012"; + + let client: Client; + let warnings: string[]; + let emitWarningSpy: { mockRestore: () => void }; + + beforeEach(() => { + _resetWarnedMessages(); + client = new Client({ + apiKey: "test-key", + apiUrl: "https://api.smith.langchain.com", + }); + warnings = []; + emitWarningSpy = jest + .spyOn(process, "emitWarning") + .mockImplementation((message) => { + warnings.push(String(message)); + }); + }); + + afterEach(() => emitWarningSpy.mockRestore()); + + it("readThread() warns for itself but not for listRuns()", async () => { + // _listRuns is what readThread delegates to; stub it so no HTTP happens. + jest + .spyOn(client as any, "_listRuns") + .mockImplementation(async function* () {} as never); + + for await (const _ of client.readThread({ + threadId: "thread-1", + projectId: PROJECT_ID, + })) { + // drain + } + + expect( + warnings.filter((w) => w.includes("readThread() is deprecated")), + ).toHaveLength(1); + expect(warnings.filter((w) => w.includes("listRuns()"))).toHaveLength(0); + }); + + it("readRun({ loadChildRuns }) warns for itself but not for listRuns()", async () => { + jest.spyOn(client as any, "_get").mockResolvedValue({ + id: RUN_ID, + name: "r", + run_type: "chain", + session_id: PROJECT_ID, + trace_id: RUN_ID, + dotted_order: "1", + } as never); + jest + .spyOn(client as any, "_listRuns") + .mockImplementation(async function* () {} as never); + + await client.readRun(RUN_ID, { loadChildRuns: true }); + + expect( + warnings.filter((w) => w.includes("readRun() is deprecated")), + ).toHaveLength(1); + expect(warnings.filter((w) => w.includes("listRuns()"))).toHaveLength(0); + }); + + it("_listRuns() does not warn at all", async () => { + jest + .spyOn(client as any, "_getCursorPaginatedList") + .mockImplementation(async function* () {} as never); + + for await (const _ of client._listRuns({ projectId: PROJECT_ID })) { + // drain + } + + expect(warnings).toHaveLength(0); + }); +}); + +describe("run sharing deprecations", () => { + const RUN_ID = "00000000-0000-4000-8000-000000000021"; + const SHARE_TOKEN = "00000000-0000-4000-8000-000000000022"; + + let client: Client; + let warnings: string[]; + let emitWarningSpy: { mockRestore: () => void }; + + beforeEach(() => { + _resetWarnedMessages(); + client = new Client({ + apiKey: "test-key", + apiUrl: "https://api.smith.langchain.com", + }); + warnings = []; + emitWarningSpy = jest + .spyOn(process, "emitWarning") + .mockImplementation((message) => { + warnings.push(String(message)); + }); + }); + + afterEach(() => emitWarningSpy.mockRestore()); + + /** Stub only the transport, so each real method body still executes. */ + const stubFetch = (body: unknown) => + jest.spyOn(client as any, "_fetch").mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => body, + text: async () => "", + } as never); + + it.each([ + [ + "shareRun", + { share_token: SHARE_TOKEN }, + () => client.shareRun(RUN_ID), + "client.runs.share.create()", + ], + [ + "unshareRun", + {}, + () => client.unshareRun(RUN_ID), + "client.runs.share.delete()", + ], + [ + "readRunSharedLink", + { share_token: SHARE_TOKEN }, + () => client.readRunSharedLink(RUN_ID), + 'client.runs.retrieve({ selects: ["SHARE_URL"] })', + ], + [ + "listSharedRuns", + [], + () => client.listSharedRuns(SHARE_TOKEN), + "client.public.runs.query()", + ], + ])( + "%s() warns once and names its replacement", + async (name, body, call, replacement) => { + stubFetch(body); + await call(); + + const emitted = warnings.filter((w) => + w.includes(`${name}() is deprecated`), + ); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toContain(`Use ${replacement} instead.`); + expect(emitted[0]).toContain("#share-and-read-public-runs"); + }, + ); +}); diff --git a/src/lib/tests/client_headers.test.ts b/src/lib/tests/client_headers.test.ts new file mode 100644 index 0000000..7377197 --- /dev/null +++ b/src/lib/tests/client_headers.test.ts @@ -0,0 +1,362 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, no-process-env */ +import { + describe, + it, + expect, + jest, + beforeEach, + afterEach, +} from "@jest/globals"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { Client } from "../client.js"; +import { __version__ } from "../index.js"; + +describe("caller-supplied headers", () => { + const mockJsonResponse = () => + jest.fn( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const requestHeaders = (mockFetch: jest.Mock): Headers => { + const [, init] = mockFetch.mock.calls[0] as unknown as [ + RequestInfo | URL, + RequestInit, + ]; + return new Headers(init.headers); + }; + + it("forwards config.headers to the generated OpenAPI client", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + apiKey: "test-api-key", + headers: { "X-Corp-Trace": "abc123" }, + fetchImplementation: mockFetch as any, + }); + + await (client as any).openAPIClient.info.list(); + + const headers = requestHeaders(mockFetch); + expect(headers.get("x-corp-trace")).toBe("abc123"); + // The SDK's own auth header must survive. + expect(headers.get("x-api-key")).toBe("test-api-key"); + }); + + it("forwards fetchOptions.headers to the generated OpenAPI client", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + fetchOptions: { headers: { "X-Corp-Trace": "abc123" } }, + fetchImplementation: mockFetch as any, + }); + + await (client as any).openAPIClient.info.list(); + + expect(requestHeaders(mockFetch).get("x-corp-trace")).toBe("abc123"); + }); + + it.each([ + ["config.headers", { headers: { "x-api-key": "hijacked" } }], + [ + "fetchOptions.headers", + { fetchOptions: { headers: { "x-api-key": "hijacked" } } }, + ], + ])( + "does not let %s override the auth header on the generated client", + async (_name, config) => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "real-key", + fetchImplementation: mockFetch as any, + ...config, + }); + + await (client as any).openAPIClient.info.list(); + + expect(requestHeaders(mockFetch).get("x-api-key")).toBe("real-key"); + }, + ); + + it("keeps required headers when fetchOptions.headers is set on handwritten calls", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "real-key", + workspaceId: "11111111-1111-1111-1111-111111111111", + fetchOptions: { + headers: { "X-Corp-Trace": "abc123", "x-api-key": "hijacked" }, + }, + fetchImplementation: mockFetch as any, + }); + + await client.readProject({ + projectId: "22222222-2222-2222-2222-222222222222", + }); + + const headers = requestHeaders(mockFetch); + expect(headers.get("x-corp-trace")).toBe("abc123"); + // Previously `...this.fetchOptions` replaced the whole header set. + expect(headers.get("x-api-key")).toBe("real-key"); + expect(headers.get("x-tenant-id")).toBe( + "11111111-1111-1111-1111-111111111111", + ); + }); + + it("sends the SDK User-Agent on the generated OpenAPI client", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + fetchImplementation: mockFetch as any, + }); + + await (client as any).openAPIClient.info.list(); + + // Not the generated client's own `Langsmith/JS `, which + // is pinned to a placeholder version and identifies the wrong package. + expect(requestHeaders(mockFetch).get("user-agent")).toBe( + `langsmith-js/${__version__}`, + ); + }); + + it("sends the same User-Agent on handwritten and generated calls", async () => { + const generatedFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + fetchImplementation: generatedFetch as any, + }); + + await (client as any).openAPIClient.info.list(); + + const handwrittenFetch = mockJsonResponse(); + (client as any).fetchImplementation = handwrittenFetch; + await client.readProject({ + projectId: "22222222-2222-2222-2222-222222222222", + }); + + expect(requestHeaders(generatedFetch).get("user-agent")).toBe( + requestHeaders(handwrittenFetch).get("user-agent"), + ); + }); + + it.each([ + ["config.headers", { headers: { "User-Agent": "my-app/1.0" } }], + [ + "fetchOptions.headers", + { fetchOptions: { headers: { "user-agent": "my-app/1.0" } } }, + ], + ])( + "lets %s override the User-Agent on the generated client", + async (_name, config) => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + fetchImplementation: mockFetch as any, + ...config, + }); + + await (client as any).openAPIClient.info.list(); + + expect(requestHeaders(mockFetch).get("user-agent")).toBe("my-app/1.0"); + }, + ); + + it("rebuilds the generated client when headers are set after construction", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + fetchImplementation: mockFetch as any, + }); + + client.headers = { "X-Corp-Trace": "set-later" }; + await (client as any).openAPIClient.info.list(); + + expect(requestHeaders(mockFetch).get("x-corp-trace")).toBe("set-later"); + }); + + it("picks up headers mutated through the getter, which never fires the setter", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: "test-api-key", + headers: { "X-Corp-Trace": "initial" }, + fetchImplementation: mockFetch as any, + }); + + // `get headers` hands back the caller's own object by reference. + client.headers["X-Corp-Trace"] = "mutated"; + client.headers["X-Added-Later"] = "added"; + await (client as any).openAPIClient.info.list(); + + const headers = requestHeaders(mockFetch); + expect(headers.get("x-corp-trace")).toBe("mutated"); + expect(headers.get("x-added-later")).toBe("added"); + }); + + it("stops forwarding a caller Authorization once profile auth supplies one", async () => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiKey: undefined, + headers: { Authorization: "Bearer caller-token" }, + fetchImplementation: mockFetch as any, + }); + // A profile holding only a refresh token has no auth header yet, so the + // caller's survives; once refreshed, the profile's must win instead. + let profileHeader: { name: string; value: string } | undefined; + (client as any).profileAuth = { + currentAuthHeader: () => profileHeader, + getAuthHeader: async () => profileHeader, + isProfileAuthorizationHeader: (value: string) => + value === profileHeader?.value, + }; + + await (client as any).openAPIClient.info.list(); + expect(requestHeaders(mockFetch).get("authorization")).toBe( + "Bearer caller-token", + ); + + profileHeader = { name: "Authorization", value: "Bearer profile-token" }; + const secondFetch = mockJsonResponse(); + (client as any).fetchImplementation = secondFetch; + await (client as any).openAPIClient.info.list(); + + // The generated client must not still be holding the stale snapshot. + expect(requestHeaders(secondFetch).get("authorization")).toBe( + "Bearer profile-token", + ); + }); + + it("rejects malformed caller-supplied header names", () => { + expect( + () => + new Client({ + apiKey: "test-api-key", + fetchOptions: { headers: { "X-Bad\nInjected": "value" } }, + }), + ).toThrow(); + expect( + () => + new Client({ + apiKey: "test-api-key", + headers: { "X-Bad\nInjected": "value" }, + }), + ).toThrow(); + const client = new Client({ apiKey: "test-api-key" }); + expect(() => { + client.headers = { "X-Bad\nInjected": "value" }; + }).toThrow(); + }); +}); + +describe("caller-supplied header collisions", () => { + it("overrides rather than appends when both channels set the same header", () => { + const client = new Client({ + apiKey: "test-api-key", + headers: { "X-Corp-Trace": "from-config" }, + fetchOptions: { headers: { "x-corp-trace": "from-fetch-options" } }, + }); + + const merged = (client as any)._mergedHeaders; + // A plain spread would leave both spellings and yield "a, b" once + // `Headers` appends them. + expect(new Headers(merged).get("x-corp-trace")).toBe("from-fetch-options"); + }); + + it.each([ + ["config.headers", (h: Record) => ({ headers: h })], + [ + "fetchOptions.headers", + (h: Record) => ({ fetchOptions: { headers: h } }), + ], + ])( + "resolves duplicate spellings within %s to the last value", + (_name, build) => { + const client = new Client({ + apiKey: "test-api-key", + ...build({ "X-Corp-Trace": "first", "x-corp-trace": "second" }), + }); + + const merged = (client as any)._mergedHeaders; + expect(new Headers(merged).get("x-corp-trace")).toBe("second"); + }, + ); + + it.each([["x-api-key"], ["X-Api-Key"]])( + "never lets a caller-supplied %s reach the required headers", + (name) => { + const client = new Client({ + apiKey: "real-key", + headers: { [name]: "hijacked" }, + }); + + const merged = (client as any)._mergedHeaders; + expect(new Headers(merged).get("x-api-key")).toBe("real-key"); + }, + ); +}); + +describe("caller-supplied credentials", () => { + const originalEnv = process.env; + let tempDir: string; + + beforeEach(() => { + process.env = { ...originalEnv }; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "langsmith-caller-auth-")); + // No configured credential of any kind: no env key, and a profile config + // with no auth so the machine's real profile can't supply one. + delete process.env.LANGSMITH_API_KEY; + delete process.env.LANGCHAIN_API_KEY; + delete process.env.LANGSMITH_PROFILE; + delete process.env.LANGSMITH_WORKSPACE_ID; + delete process.env.LANGCHAIN_WORKSPACE_ID; + const configPath = path.join(tempDir, "config.json"); + fs.writeFileSync(configPath, `${JSON.stringify({ profiles: {} })}\n`); + process.env.LANGSMITH_CONFIG_FILE = configPath; + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + process.env = originalEnv; + }); + + const mockJsonResponse = () => + jest.fn( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + it.each([ + ["Authorization", "Bearer caller-token"], + ["x-api-key", "caller-key"], + ])( + "keeps a caller-supplied %s when no credential is configured", + async (name, value) => { + const mockFetch = mockJsonResponse(); + const client = new Client({ + apiUrl: "https://api.smith.langchain.com", + headers: { [name]: value }, + fetchImplementation: mockFetch as any, + }); + + // The SDK has no credential of its own to apply, so dropping the + // caller's would leave the request unauthenticated. + expect( + new Headers((client as any)._mergedHeaders).get(name.toLowerCase()), + ).toBe(value); + + await (client as any).openAPIClient.info.list(); + + const [, init] = mockFetch.mock.calls[0] as unknown as [ + RequestInfo | URL, + RequestInit, + ]; + expect(new Headers(init.headers).get(name.toLowerCase())).toBe(value); + }, + ); +}); diff --git a/src/lib/tests/client_retry.test.ts b/src/lib/tests/client_retry.test.ts new file mode 100644 index 0000000..f06a51d --- /dev/null +++ b/src/lib/tests/client_retry.test.ts @@ -0,0 +1,162 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { v4 } from "../utils/uuid/src/index.js"; +import { createServer, Server } from "http"; +import { Client } from "../client.js"; +import { AsyncCaller } from "../utils/async_caller.js"; + +describe("Client timeout and retry behavior", () => { + let testServer: Server; + const port = 9876; + let requestCount = 0; + let testMode = "timeout"; + + beforeAll((done) => { + testServer = createServer((req, res) => { + requestCount++; + + if (testMode === "timeout") { + // Never respond to simulate timeout + return; + } else if (testMode === "408_timeout") { + if (requestCount <= 2) { + // First two requests: return 408 (should retry) + res.writeHead(408, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Request Timeout" })); + return; + } + // Third request: succeed + const url = req.url || ""; + const runId = url.split("/runs/")[1] || "test-run-id"; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: runId, + name: "test_run", + run_type: "llm", + status: "success", + start_time: new Date().toISOString(), + end_time: new Date().toISOString(), + }), + ); + } else if (testMode === "429_retry") { + if (requestCount <= 2) { + // First two requests: return 429 (should retry) + res.writeHead(429, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Too Many Requests" })); + return; + } + // Third request: succeed + const url = req.url || ""; + const runId = url.split("/runs/")[1] || "test-run-id"; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id: runId, + name: "test_run", + run_type: "llm", + status: "success", + start_time: new Date().toISOString(), + end_time: new Date().toISOString(), + }), + ); + } else if (testMode === "400_no_retry") { + // Return 400 Bad Request status (should not retry) + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Bad Request" })); + } + }); + + testServer.listen(port, done); + }); + + afterAll((done) => { + testServer.close(done); + }); + + it("should retry on 408 timeout status and eventually succeed", async () => { + testMode = "408_timeout"; + requestCount = 0; + + const client = new Client({ + apiKey: "test-api-key", + apiUrl: `http://localhost:${port}`, + timeout_ms: 1000, + autoBatchTracing: true, + }); + + // Override caller with lower max retries for faster test + (client as any).caller = new AsyncCaller({ + maxRetries: 3, + maxConcurrency: 1, + }); + + // This should succeed after retries + const result = await client.readRun(v4()); + expect(result).toHaveProperty("id"); + expect(result.name).toBe("test_run"); + expect(requestCount).toBe(3); // Should have made 3 requests (2 failures + 1 success) + }); + + it("should retry on 429 status and eventually succeed", async () => { + testMode = "429_retry"; + requestCount = 0; + + const client = new Client({ + apiKey: "test-api-key", + apiUrl: `http://localhost:${port}`, + timeout_ms: 1000, + autoBatchTracing: true, + }); + + // Override caller with lower max retries for faster test + (client as any).caller = new AsyncCaller({ + maxRetries: 3, + maxConcurrency: 1, + }); + + // This should succeed after retries + const result = await client.readRun(v4()); + expect(result).toHaveProperty("id"); + expect(result.name).toBe("test_run"); + expect(requestCount).toBe(3); // Should have made 3 requests (2 failures + 1 success) + }); + + it("should not retry on timeout", async () => { + testMode = "timeout"; + requestCount = 0; + + const client = new Client({ + apiKey: "test-api-key", + apiUrl: `http://localhost:${port}`, + timeout_ms: 1000, + autoBatchTracing: true, + }); + + await expect(client.readRun(v4())).rejects.toThrow(); + expect(requestCount).toBe(1); + }); + + it("should not retry on 400 Bad Request", async () => { + testMode = "400_no_retry"; + requestCount = 0; + + const client = new Client({ + apiKey: "test-api-key", + apiUrl: `http://localhost:${port}`, + timeout_ms: 1000, + autoBatchTracing: true, + }); + + // Override caller with retries enabled + (client as any).caller = new AsyncCaller({ + maxRetries: 3, + maxConcurrency: 1, + }); + + // Should fail immediately without retries on 400 status + await expect(client.readRun(v4())).rejects.toThrow(); + + // Should only make one request (no retries) + expect(requestCount).toBe(1); + }); +}); diff --git a/src/lib/tests/construct_url.test.ts b/src/lib/tests/construct_url.test.ts new file mode 100644 index 0000000..21df9ac --- /dev/null +++ b/src/lib/tests/construct_url.test.ts @@ -0,0 +1,53 @@ +import { constructUrl } from "../utils/url.js"; + +// Same cases as Python's test_construct_url. +test.each([ + [ + "https://api.smith.langchain.com", + "/runs", + "https://api.smith.langchain.com/runs", + ], + [ + "https://api.smith.langchain.com", + "/api/runs", + "https://api.smith.langchain.com/api/runs", + ], + [ + "https://self-hosted.smith.langchain.com/api", + "/v1/stuff", + "https://self-hosted.smith.langchain.com/api/v1/stuff", + ], + [ + "https://self-hosted.smith.langchain.com/api", + "/api/v1/stuff", + "https://self-hosted.smith.langchain.com/api/v1/stuff", + ], + [ + "https://self-hosted.smith.langchain.com/api/v1", + "/runs", + "https://self-hosted.smith.langchain.com/api/v1/runs", + ], + [ + "https://self-hosted.smith.langchain.com/api/v1", + "/api/runs", + "https://self-hosted.smith.langchain.com/api/runs", + ], + [ + "https://aks.smith.langchain.dev/api", + "/api/v1/public/download?jwt=abc", + "https://aks.smith.langchain.dev/api/v1/public/download?jwt=abc", + ], + [ + "https://aks.smith.langchain.dev/api", + "https://cdn.example.com/file", + "https://cdn.example.com/file", + ], +])("constructUrl(%s, %s)", (apiUrl, pathname, expected) => { + expect(constructUrl(apiUrl, pathname)).toBe(expected); +}); + +test("constructUrl rejects an api url without a scheme", () => { + expect(() => constructUrl("", "/some/path")).toThrow( + "api_url must start with 'http://'", + ); +}); diff --git a/src/lib/tests/context.int.test.ts b/src/lib/tests/context.int.test.ts new file mode 100644 index 0000000..c9f6531 --- /dev/null +++ b/src/lib/tests/context.int.test.ts @@ -0,0 +1,237 @@ +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { Client } from "../client.js"; +import { AgentContext, FileEntry, SkillContext } from "../schemas.js"; + +function agentIdentifier(): string { + return `-/ctx-test-js-agent-${uuidv4().slice(0, 8)}`; +} + +function skillIdentifier(): string { + return `-/ctx-test-js-skill-${uuidv4().slice(0, 8)}`; +} + +const TOOLS_JSON = JSON.stringify({ + tools: [{ name: "read_file", type: "builtin" }], + interrupt_config: {}, +}); + +describe("Context integration (agent/skill)", () => { + const client = new Client(); + + async function cleanupAgent(identifier: string) { + try { + await client.deleteAgent(identifier); + } catch (_) { + /* best effort */ + } + } + + async function cleanupSkill(identifier: string) { + try { + await client.deleteSkill(identifier); + } catch (_) { + /* best effort */ + } + } + + test("pushAgent + pullAgent roundtrip", async () => { + const identifier = agentIdentifier(); + try { + const url = await client.pushAgent(identifier, { + files: { + "AGENTS.md": { type: "file", content: "# Test Agent\n" }, + }, + description: "integration test agent (js)", + }); + expect(url).toContain("/context/"); + + const agent: AgentContext = await client.pullAgent(identifier); + expect(agent.files["AGENTS.md"]).toBeDefined(); + const entry = agent.files["AGENTS.md"] as FileEntry; + expect(entry.type).toBe("file"); + expect(entry.content).toBe("# Test Agent\n"); + } finally { + await cleanupAgent(identifier); + } + }); + + test("pushSkill + pullSkill roundtrip", async () => { + const identifier = skillIdentifier(); + try { + const url = await client.pushSkill(identifier, { + files: { + "SKILL.md": { type: "file", content: "# Test Skill\n" }, + }, + description: "integration test skill (js)", + }); + expect(url).toContain("/context/"); + + const skill: SkillContext = await client.pullSkill(identifier); + expect(skill.files["SKILL.md"]).toBeDefined(); + const entry = skill.files["SKILL.md"] as FileEntry; + expect(entry.content).toBe("# Test Skill\n"); + } finally { + await cleanupSkill(identifier); + } + }); + + test("pushAgent + pullAgent roundtrip for tools.json", async () => { + const identifier = agentIdentifier(); + try { + const url = await client.pushAgent(identifier, { + files: { + "tools.json": { type: "file", content: TOOLS_JSON }, + }, + }); + expect(url).toContain("/context/"); + + const agent: AgentContext = await client.pullAgent(identifier); + expect(agent.files["tools.json"]).toBeDefined(); + const entry = agent.files["tools.json"] as FileEntry; + expect(entry.type).toBe("file"); + expect(entry.content).toBe(TOOLS_JSON); + } finally { + await cleanupAgent(identifier); + } + }); + + test("agentExists reflects create/delete lifecycle", async () => { + const identifier = agentIdentifier(); + try { + expect(await client.agentExists(identifier)).toBe(false); + + await client.pushAgent(identifier, { + files: { "AGENTS.md": { type: "file", content: "x" } }, + }); + expect(await client.agentExists(identifier)).toBe(true); + + await client.deleteAgent(identifier); + expect(await client.agentExists(identifier)).toBe(false); + } finally { + await cleanupAgent(identifier); + } + }); + + test("skillExists reflects create/delete lifecycle", async () => { + const identifier = skillIdentifier(); + try { + expect(await client.skillExists(identifier)).toBe(false); + + await client.pushSkill(identifier, { + files: { "SKILL.md": { type: "file", content: "x" } }, + }); + expect(await client.skillExists(identifier)).toBe(true); + + await client.deleteSkill(identifier); + expect(await client.skillExists(identifier)).toBe(false); + } finally { + await cleanupSkill(identifier); + } + }); + + test("pushAgent with null entry deletes that file", async () => { + const identifier = agentIdentifier(); + try { + await client.pushAgent(identifier, { + files: { + "keep.md": { type: "file", content: "keep" }, + "remove.md": { type: "file", content: "remove" }, + }, + }); + let agent = await client.pullAgent(identifier); + expect(agent.files["keep.md"]).toBeDefined(); + expect(agent.files["remove.md"]).toBeDefined(); + + await client.pushAgent(identifier, { + files: { "remove.md": null }, + }); + agent = await client.pullAgent(identifier); + expect(agent.files["keep.md"]).toBeDefined(); + expect(agent.files["remove.md"]).toBeUndefined(); + } finally { + await cleanupAgent(identifier); + } + }); + + test("pushAgent second commit updates file content", async () => { + const identifier = agentIdentifier(); + try { + await client.pushAgent(identifier, { + files: { "AGENTS.md": { type: "file", content: "v1" } }, + }); + await client.pushAgent(identifier, { + files: { "AGENTS.md": { type: "file", content: "v2" } }, + }); + + const agent = await client.pullAgent(identifier); + const entry = agent.files["AGENTS.md"] as FileEntry; + expect(entry.content).toBe("v2"); + } finally { + await cleanupAgent(identifier); + } + }); + + test("deleteAgent removes the repo", async () => { + const identifier = agentIdentifier(); + await client.pushAgent(identifier, { + files: { "AGENTS.md": { type: "file", content: "x" } }, + }); + await client.pullAgent(identifier); + + await client.deleteAgent(identifier); + + await expect(client.pullAgent(identifier)).rejects.toThrow(); + }); + + test("listAgents returns pushed agent", async () => { + const identifier = agentIdentifier(); + const handle = identifier.split("/")[1]; + try { + await client.pushAgent(identifier, { + files: { "AGENTS.md": { type: "file", content: "x" } }, + }); + let found = false; + for await (const repo of client.listAgents({ query: handle })) { + if (repo.repo_handle === handle) { + found = true; + break; + } + } + expect(found).toBe(true); + } finally { + await cleanupAgent(identifier); + } + }); + + test("deleteSkill removes the repo", async () => { + const identifier = skillIdentifier(); + await client.pushSkill(identifier, { + files: { "SKILL.md": { type: "file", content: "x" } }, + }); + await client.pullSkill(identifier); + + await client.deleteSkill(identifier); + + await expect(client.pullSkill(identifier)).rejects.toThrow(); + }); + + test("listSkills returns pushed skill", async () => { + const identifier = skillIdentifier(); + const handle = identifier.split("/")[1]; + try { + await client.pushSkill(identifier, { + files: { "SKILL.md": { type: "file", content: "x" } }, + }); + let found = false; + for await (const repo of client.listSkills({ query: handle })) { + if (repo.repo_handle === handle) { + found = true; + break; + } + } + expect(found).toBe(true); + } finally { + await cleanupSkill(identifier); + } + }); +}); diff --git a/src/lib/tests/context.test.ts b/src/lib/tests/context.test.ts new file mode 100644 index 0000000..36be1f6 --- /dev/null +++ b/src/lib/tests/context.test.ts @@ -0,0 +1,539 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest } from "@jest/globals"; +import { Client } from "../client.js"; +import { LangSmithConflictError } from "../utils/error.js"; + +const WORKSPACE_ID = "00000000-0000-0000-0000-000000000000"; +const ORGANIZATION_QUERY = `?organizationId=${WORKSPACE_ID}`; + +function _mockClient() { + const client = new Client({ apiKey: "test-api-key" }); + jest.spyOn(client as any, "_currentTenantIsOwner").mockResolvedValue(true); + jest.spyOn(client as any, "_getSettings").mockResolvedValue({ + id: WORKSPACE_ID, + display_name: "Test Workspace", + created_at: "2026-05-27T00:00:00Z", + tenant_handle: "workspace-handle", + }); + jest + .spyOn(client as any, "_ownerConflictError") + .mockImplementation(async () => new Error("owner mismatch")); + return client; +} + +function _response(body: unknown, status = 200): any { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + headers: new Headers(), + }; +} + +function _setFetchSequence(client: Client, responses: any[]): jest.Mock { + const spy = jest.spyOn(client as any, "_fetch"); + for (const res of responses) { + spy.mockResolvedValueOnce(res); + } + return spy as unknown as jest.Mock; +} + +describe("Context (agent/skill) on Client", () => { + describe("pullAgent", () => { + it("hits the directories GET URL with repo_type query param", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ + commit_id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + files: { "main.py": { type: "file", content: "print('hi')" } }, + }), + ]); + + const agent = await client.pullAgent("owner/my-agent"); + + const [url, init] = fetchSpy.mock.calls[0] as [string, any]; + expect(url).toContain( + "/v1/platform/hub/repos/owner/my-agent/directories", + ); + expect(url).toContain("repo_type=agent"); + expect(init.method).toBe("GET"); + expect(agent.commit_id).toBe("00000000-0000-0000-0000-000000000000"); + expect(agent.commit_hash).toBe("abc12345"); + expect(agent.files["main.py"]).toEqual({ + type: "file", + content: "print('hi')", + }); + }); + + it("passes commit query param when version is supplied", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ + commit_id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + files: {}, + }), + ]); + + await client.pullAgent("owner/my-agent", { version: "abc12345" }); + + const url = (fetchSpy.mock.calls[0] as [string, any])[0]; + expect(url).toContain("commit=abc12345"); + }); + }); + + describe("pushAgent", () => { + it("rejects short parentCommit", async () => { + const client = _mockClient(); + await expect( + client.pushAgent("-/repo", { files: {}, parentCommit: "abc" }), + ).rejects.toThrow(/8-64/); + }); + + it("rejects invalid repo_handle when creating", async () => { + const client = _mockClient(); + _setFetchSequence(client, [ + // GET /repos/-/BadName → 404 (doesn't exist) + _response({ detail: "Not Found" }, 404), + ]); + await expect( + client.pushAgent("-/BadName", { + files: { "main.py": { type: "file", content: "x" } }, + }), + ).rejects.toThrow(/Invalid repo_handle/); + }); + + it.each([ + ["a", false], + ["foo_bar-baz1", false], + ["BadName", true], + ["1agent", true], + ["-agent", true], + ["agent.name", true], + ])("repo_handle %p rejected=%p", async (handle, shouldReject) => { + const client = _mockClient(); + _setFetchSequence(client, [ + // _repoExists → 404 so the create path runs and validates the handle + _response({ detail: "Not Found" }, 404), + // remaining responses only consumed for valid handles + _response({ repo: { id: "r1" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + const call = client.pushAgent(`-/${handle}`, { + files: { "main.py": { type: "file", content: "x" } }, + }); + if (shouldReject) { + await expect(call).rejects.toThrow(/Invalid repo_handle/); + } else { + await expect(call).resolves.toBeDefined(); + } + }); + + it("creates new repo then commits files when repo does not exist", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + // _repoExists GET → 404 + _response({ detail: "Not Found" }, 404), + // _createRepo POST → ok + _response({ repo: { id: "r1" } }), + // commit POST → ok + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + const url = await client.pushAgent("-/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + }); + expect(url).toBe( + `https://smith.langchain.com/context/my-agent/abc12345${ORGANIZATION_QUERY}`, + ); + + const calls = fetchSpy.mock.calls as [string, any][]; + expect(calls).toHaveLength(3); + // 1. exists check + expect(calls[0][0]).toContain("/repos/-/my-agent"); + expect(calls[0][1].method).toBe("GET"); + // 2. create repo + expect(calls[1][0]).toContain("/repos/"); + expect(calls[1][1].method).toBe("POST"); + const createBody = JSON.parse(calls[1][1].body); + expect(createBody.repo_handle).toBe("my-agent"); + expect(createBody.repo_type).toBe("agent"); + // 3. commit + expect(calls[2][0]).toContain( + "/v1/platform/hub/repos/-/my-agent/directories/commits", + ); + expect(calls[2][1].method).toBe("POST"); + const commitBody = JSON.parse(calls[2][1].body); + expect(commitBody.files).toEqual({ + "main.py": { type: "file", content: "x" }, + }); + }); + + it("accepts tools.json as a normal file entry", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ detail: "Not Found" }, 404), + _response({ repo: { id: "r1" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + await client.pushAgent("-/my-agent", { + files: { + "tools.json": { + type: "file", + content: JSON.stringify({ + tools: [{ name: "read_file", type: "builtin" }], + interrupt_config: {}, + }), + }, + }, + }); + + const calls = fetchSpy.mock.calls as [string, any][]; + const commitBody = JSON.parse(calls[2][1].body); + expect(commitBody.files).toEqual({ + "tools.json": { + type: "file", + content: JSON.stringify({ + tools: [{ name: "read_file", type: "builtin" }], + interrupt_config: {}, + }), + }, + }); + }); + + it("patches metadata when repo exists and metadata fields are provided", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + // _repoExists → 200 (exists) + _response({ repo: { repo_handle: "my-agent" } }), + // _updateRepoMetadata PATCH + _response({}), + // commit + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + await client.pushAgent("-/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + description: "new desc", + }); + + const calls = fetchSpy.mock.calls as [string, any][]; + expect(calls).toHaveLength(3); + expect(calls[1][0]).toContain("/repos/-/my-agent"); + expect(calls[1][1].method).toBe("PATCH"); + const patchBody = JSON.parse(calls[1][1].body); + expect(patchBody).toEqual({ description: "new desc" }); + }); + + it("skips metadata patch when repo exists and no metadata fields given", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ repo: { repo_handle: "my-agent" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + await client.pushAgent("-/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + }); + + const calls = fetchSpy.mock.calls as [string, any][]; + expect(calls).toHaveLength(2); + expect(calls[1][1].method).toBe("POST"); + }); + + it("swallows 409 from _createRepo (repo already exists)", async () => { + const client = _mockClient(); + _setFetchSequence(client, [ + _response({ detail: "Not Found" }, 404), + // _createRepo POST → 409 + _response({ detail: "already exists" }, 409), + // commit + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + await expect( + client.pushAgent("-/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + }), + ).resolves.toBe( + `https://smith.langchain.com/context/my-agent/abc12345${ORGANIZATION_QUERY}`, + ); + }); + + it("returns context URL without explicit owner", async () => { + const client = _mockClient(); + _setFetchSequence(client, [ + _response({ detail: "Not Found" }, 404), + _response({ repo: { id: "r1" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + const url = await client.pushAgent("explicit-owner/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + }); + expect(url).toBe( + `https://smith.langchain.com/context/my-agent/abc12345${ORGANIZATION_QUERY}`, + ); + }); + + it("returns context URL with organizationId when tenant_handle is missing", async () => { + const client = _mockClient(); + const settingsSpy = jest.spyOn(client as any, "_getSettings"); + settingsSpy.mockResolvedValue({ + id: WORKSPACE_ID, + display_name: "Test Workspace", + created_at: "2026-05-27T00:00:00Z", + tenant_handle: undefined, + }); + _setFetchSequence(client, [ + _response({ detail: "Not Found" }, 404), + _response({ repo: { id: "r1" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + const url = await client.pushAgent("-/my-agent", { + files: { "main.py": { type: "file", content: "x" } }, + }); + expect(url).toBe( + `https://smith.langchain.com/context/my-agent/abc12345${ORGANIZATION_QUERY}`, + ); + expect(settingsSpy).toHaveBeenCalled(); + }); + + it("supports name-only identifier and returns context URL", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ detail: "Not Found" }, 404), + _response({ repo: { id: "r1" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + const url = await client.pushAgent("email-assistant", { + files: { "main.py": { type: "file", content: "x" } }, + }); + expect(url).toBe( + `https://smith.langchain.com/context/email-assistant/abc12345${ORGANIZATION_QUERY}`, + ); + + const calls = fetchSpy.mock.calls as [string, any][]; + expect(calls[0][0]).toContain("/repos/-/email-assistant"); + expect(calls[2][0]).toContain( + "/v1/platform/hub/repos/-/email-assistant/directories/commits", + ); + }); + + it("serializes null entry for deletion", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ repo: { repo_handle: "my-agent" } }), + _response({ + commit: { + id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + }, + }), + ]); + + await client.pushAgent("-/my-agent", { + files: { "gone.md": null }, + }); + + const commitCall = (fetchSpy.mock.calls as [string, any][])[1]; + const body = JSON.parse(commitCall[1].body); + expect(body.files).toEqual({ "gone.md": null }); + }); + }); + + describe("deleteAgent", () => { + it("hits DELETE on the directories URL", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [_response({}, 204)]); + await client.deleteAgent("-/old-agent"); + const [url, init] = fetchSpy.mock.calls[0] as [string, any]; + expect(url).toContain("/v1/platform/hub/repos/-/old-agent/directories"); + expect(init.method).toBe("DELETE"); + }); + + it("throws owner conflict before calling DELETE", async () => { + const client = _mockClient(); + jest + .spyOn(client as any, "_currentTenantIsOwner") + .mockResolvedValue(false); + const fetchSpy = jest.spyOn(client as any, "_fetch"); + await expect(client.deleteAgent("other/repo")).rejects.toThrow( + /owner mismatch/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + }); + + describe("apiUrl ending in /v1", () => { + function _mockV1Client() { + const client = new Client({ + apiKey: "test-api-key", + apiUrl: "https://example.com/api/v1", + }); + jest + .spyOn(client as any, "_currentTenantIsOwner") + .mockResolvedValue(true); + jest.spyOn(client as any, "_getSettings").mockResolvedValue({ + id: WORKSPACE_ID, + display_name: "Test Workspace", + created_at: "2026-05-27T00:00:00Z", + tenant_handle: "workspace-handle", + }); + return client; + } + + it("pullAgent does not duplicate the /v1 prefix", async () => { + const client = _mockV1Client(); + const fetchSpy = _setFetchSequence(client, [ + _response({ + commit_id: "00000000-0000-0000-0000-000000000000", + commit_hash: "abc12345", + files: {}, + }), + ]); + await client.pullAgent("owner/my-agent"); + const [url] = fetchSpy.mock.calls[0] as [string, any]; + expect(url).toContain( + "/api/v1/platform/hub/repos/owner/my-agent/directories", + ); + expect(url).not.toContain("/v1/v1/"); + }); + + it("deleteAgent does not duplicate the /v1 prefix", async () => { + const client = _mockV1Client(); + const fetchSpy = _setFetchSequence(client, [_response({}, 204)]); + await client.deleteAgent("-/old-agent"); + const [url] = fetchSpy.mock.calls[0] as [string, any]; + expect(url).toContain( + "/api/v1/platform/hub/repos/-/old-agent/directories", + ); + expect(url).not.toContain("/v1/v1/"); + }); + }); + + describe("agentExists / skillExists", () => { + it("agentExists returns true on 200", async () => { + const client = _mockClient(); + _setFetchSequence(client, [_response({ repo: { id: "r1" } })]); + expect(await client.agentExists("-/my-agent")).toBe(true); + }); + + it("agentExists returns false on 404", async () => { + const client = _mockClient(); + _setFetchSequence(client, [_response({ detail: "Not Found" }, 404)]); + expect(await client.agentExists("-/nope")).toBe(false); + }); + + it("skillExists returns true on 200", async () => { + const client = _mockClient(); + _setFetchSequence(client, [_response({ repo: { id: "r1" } })]); + expect(await client.skillExists("-/my-skill")).toBe(true); + }); + + it("skillExists returns false on 404", async () => { + const client = _mockClient(); + _setFetchSequence(client, [_response({ detail: "Not Found" }, 404)]); + expect(await client.skillExists("-/nope")).toBe(false); + }); + }); + + describe("listAgents", () => { + it("sends repo_type=agent and paginates", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ + repos: [{ repo_handle: "a1" }, { repo_handle: "a2" }], + total: 2, + }), + ]); + + const items: any[] = []; + for await (const item of client.listAgents()) { + items.push(item); + } + + expect(items).toHaveLength(2); + const url = (fetchSpy.mock.calls[0] as [string, any])[0]; + expect(url).toContain("/repos"); + expect(url).toContain("repo_type=agent"); + }); + + it("forwards isPublic and query params", async () => { + const client = _mockClient(); + const fetchSpy = _setFetchSequence(client, [ + _response({ repos: [], total: 0 }), + ]); + + for await (const _ of client.listSkills({ + isPublic: true, + query: "foo", + })) { + /* noop */ + } + + const url = (fetchSpy.mock.calls[0] as [string, any])[0]; + expect(url).toContain("repo_type=skill"); + expect(url).toContain("is_public=true"); + expect(url).toContain("query=foo"); + }); + }); + + describe("LangSmithConflictError import", () => { + it("is an Error subclass", () => { + const e = new LangSmithConflictError("test"); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe("LangSmithConflictError"); + }); + }); +}); diff --git a/src/lib/tests/error.test.ts b/src/lib/tests/error.test.ts new file mode 100644 index 0000000..03209d2 --- /dev/null +++ b/src/lib/tests/error.test.ts @@ -0,0 +1,62 @@ +import { raiseForStatus } from "../utils/error.js"; + +// Real `Response` bodies are single-use streams: these assertions fail if +// `raiseForStatus` reads the body more than once. +describe("raiseForStatus", () => { + it("surfaces the server's message for a non-org-scoped 403", async () => { + const response = new Response( + JSON.stringify({ error: "usage_limit_exceeded", detail: "over quota" }), + { status: 403, statusText: "Forbidden" }, + ); + + await expect( + raiseForStatus(response, "send multipart request"), + ).rejects.toThrow( + 'Failed to send multipart request. Received status [403]: Forbidden. Message: {"error":"usage_limit_exceeded","detail":"over quota"}', + ); + }); + + it("maps the org-scoped key 403 to a workspace hint", async () => { + const response = new Response( + JSON.stringify({ error: "org_scoped_key_requires_workspace" }), + { status: 403, statusText: "Forbidden" }, + ); + + await expect(raiseForStatus(response, "create run")).rejects.toThrow( + "This API key is org-scoped and requires workspace specification.", + ); + }); + + it("surfaces a non-JSON 403 body", async () => { + const response = new Response("nginx denied this", { + status: 403, + statusText: "Forbidden", + }); + + await expect(raiseForStatus(response, "create run")).rejects.toThrow( + "Failed to create run. Received status [403]: Forbidden. Message: nginx denied this", + ); + }); + + it("surfaces an empty 403 body", async () => { + const response = new Response(null, { + status: 403, + statusText: "Forbidden", + }); + + await expect(raiseForStatus(response, "create run")).rejects.toThrow( + "Failed to create run. Received status [403]: Forbidden. Message: ", + ); + }); + + it("surfaces the body for other error statuses", async () => { + const response = new Response("no such run", { + status: 404, + statusText: "Not Found", + }); + + await expect(raiseForStatus(response, "read run")).rejects.toThrow( + "Failed to read run. Received status [404]: Not Found. Message: no such run", + ); + }); +}); diff --git a/src/lib/tests/evaluate.int.test.ts b/src/lib/tests/evaluate.int.test.ts new file mode 100644 index 0000000..b1052f0 --- /dev/null +++ b/src/lib/tests/evaluate.int.test.ts @@ -0,0 +1,725 @@ +import { + EvaluationResult, + EvaluationResults, +} from "../evaluation/evaluator.js"; +import { evaluate } from "../evaluation/_runner.js"; +import { waitUntilRunFound } from "./utils.js"; +import { Example, Run, TracerSession } from "../schemas.js"; +import { Client } from "../index.js"; +import { afterAll, beforeAll } from "@jest/globals"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; + +import * as ai from "ai"; +import { openai } from "@ai-sdk/openai"; +import { wrapAISDK } from "../experimental/vercel/index.js"; +import { requiresClickhouse } from "./utils/markers.js"; + +const { generateText } = wrapAISDK(ai); + +const TESTING_DATASET_NAME = `test_dataset_js_evaluate_${uuidv4()}`; +const TESTING_DATASET_NAME2 = `my_splits_ds_${uuidv4()}`; + +beforeAll(async () => { + const client = new Client(); + if (!(await client.hasDataset({ datasetName: TESTING_DATASET_NAME }))) { + // create a new dataset + await client.createDataset(TESTING_DATASET_NAME, { + description: + "For testing purposed. Is created & deleted for each test run.", + }); + // create examples + const res = await client.createExamples({ + inputs: [{ input: 1 }, { input: 2 }], + outputs: [{ output: 2 }, { output: 3 }], + datasetName: TESTING_DATASET_NAME, + }); + if (res.length !== 2) { + throw new Error("Failed to create examples"); + } + } +}); + +afterAll(async () => { + const client = new Client(); + await client.deleteDataset({ + datasetName: TESTING_DATASET_NAME, + }); + try { + await client.deleteDataset({ + datasetName: "my_splits_ds2", + }); + } catch { + //pass + } +}); + +// Consolidated: covers basic `evaluate`, multiple evaluators, array / AsyncIterable +// data inputs. Previously split across "evaluate can evaluate", +// "can pass multiple evaluators", "can pass AsyncIterable of Example's...", +// and "evaluate can accept array of examples". +test("evaluate handles various data inputs and evaluator shapes", async () => { + const client = new Client(); + const targetFunc = (input: Record) => { + return { + foo: input.input + 1, + }; + }; + + const makeEvaluator = () => (run: Run, example?: Example) => + Promise.resolve({ + key: "key", + score: 1, + comment: `Run: ${run.id} Example: ${example?.id}`, + }); + + // 1) Basic evaluate with no evaluators, dataset-name data. + const basicRes = await evaluate(targetFunc, { + data: TESTING_DATASET_NAME, + description: "Experiment from evaluate can evaluate integration test", + }); + expect(basicRes.results).toHaveLength(2); + for (const result of basicRes.results) { + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + expect(result.run.outputs?.foo).toBeGreaterThanOrEqual(2); + expect(result.run.outputs?.foo).toBeLessThanOrEqual(3); + expect(result.evaluationResults.results).toHaveLength(0); + } + + // 2) Multiple evaluators (object-form with evaluateRun) against dataset-name data. + const multiEvalRes = await evaluate(targetFunc, { + data: TESTING_DATASET_NAME, + evaluators: [ + { evaluateRun: makeEvaluator() }, + { evaluateRun: makeEvaluator() }, + ], + description: "can pass multiple evaluators", + }); + expect(multiEvalRes.results).toHaveLength(2); + const multiFirst = multiEvalRes.results[0]; + expect(multiFirst.evaluationResults.results).toHaveLength(2); + const multiExpected = `Run: ${multiFirst.run.id} Example: ${multiFirst.example?.id}`; + expect( + multiFirst.evaluationResults.results + .map(({ comment }) => comment) + .filter((c): c is string => !!c), + ).toEqual(expect.arrayContaining([multiExpected, multiExpected])); + + // 3) AsyncIterable of Examples as data input. + const asyncIterableRes = await evaluate(targetFunc, { + data: client.listExamples({ datasetName: TESTING_DATASET_NAME }), + evaluators: [makeEvaluator()], + description: "can pass AsyncIterable of Example's to evaluator", + }); + expect(asyncIterableRes.results).toHaveLength(2); + const asyncIterableFirst = asyncIterableRes.results[0]; + expect(asyncIterableFirst.evaluationResults.results).toHaveLength(1); + expect(asyncIterableFirst.evaluationResults.results[0].comment).toEqual( + `Run: ${asyncIterableFirst.run.id} Example: ${asyncIterableFirst.example.id}`, + ); + + // 4) Array of Examples as data input. + const examples: Example[] = []; + for await (const example of client.listExamples({ + datasetName: TESTING_DATASET_NAME, + })) { + examples.push(example); + } + const arrayRes = await evaluate(targetFunc, { + data: examples, + evaluators: [makeEvaluator()], + description: "evaluate can accept array of examples", + }); + expect(arrayRes.results).toHaveLength(2); + const arrayFirst = arrayRes.results[0]; + expect(arrayFirst.evaluationResults.results).toHaveLength(1); + expect(arrayFirst.evaluationResults.results[0].comment).toEqual( + `Run: ${arrayFirst.run.id} Example: ${arrayFirst.example.id}`, + ); +}); + +test("evaluate can repeat", async () => { + const client = new Client(); + const targetFunc = (input: Record) => { + return { + foo: input.input + 1, + }; + }; + + const evalRes = await evaluate(targetFunc, { + data: TESTING_DATASET_NAME, + description: "Experiment from evaluate can evaluate integration test", + numRepetitions: 3, + }); + expect(evalRes.results).toHaveLength(6); + + for (let i = 0; i < 6; i++) { + expect(evalRes.results[i].run).toBeDefined(); + expect(evalRes.results[i].example).toBeDefined(); + expect(evalRes.results[i].evaluationResults).toBeDefined(); + const currRun = evalRes.results[i].run; + // The examples are not always in the same order, so it should always be 2 or 3 + expect(currRun.outputs?.foo).toBeGreaterThanOrEqual(2); + expect(currRun.outputs?.foo).toBeLessThanOrEqual(3); + + const firstRunResults = evalRes.results[i].evaluationResults; + expect(firstRunResults.results).toHaveLength(0); + } + + // numRepetitions should also be honored when data is a pre-fetched Example[]. + const examples: Example[] = []; + for await (const example of client.listExamples({ + datasetName: TESTING_DATASET_NAME, + })) { + examples.push(example); + } + const arrayRepeatRes = await evaluate(targetFunc, { + data: examples, + description: "numRepetitions honored with Example[] data", + numRepetitions: 3, + }); + expect(arrayRepeatRes.results).toHaveLength(examples.length * 3); + const runCountByExampleId = new Map(); + for (const result of arrayRepeatRes.results) { + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + const exampleId = result.example.id; + runCountByExampleId.set( + exampleId, + (runCountByExampleId.get(exampleId) ?? 0) + 1, + ); + } + for (const example of examples) { + expect(runCountByExampleId.get(example.id)).toBe(3); + } +}); + +// Consolidated: covers summary evaluators end-to-end with single evaluator, +// multiple evaluators, maxConcurrency, and object-style parameters (inputs, +// outputs, referenceOutputs). Previously split across +// "evaluate can evaluate with summary evaluators", +// "can pass multiple summary evaluators", +// "max concurrency works with summary evaluators", and +// "evaluate handles summary evaluator parameters correctly". +test("evaluate works with summary evaluators in various configurations", async () => { + const targetFunc = (input: Record) => { + return { foo: input.input + 1 }; + }; + + // Traditional-signature summary evaluator (runs, examples) -> EvaluationResult. + const positionalSummaryEvaluator = ( + runs: Run[], + examples?: Example[], + ): Promise => { + const runIds = runs.map(({ id }) => id).join(", "); + const exampleIds = examples?.map(({ id }) => id).join(", "); + return Promise.resolve({ + key: "MyCustomScore", + score: 1, + comment: `Runs: ${runIds} Examples: ${exampleIds}`, + }); + }; + + // Object-style summary evaluator that uses inputs / outputs / referenceOutputs. + const objectStyleSummaryEvaluator = ({ + inputs, + outputs, + referenceOutputs, + }: { + inputs?: Record[]; + outputs?: Record[]; + referenceOutputs?: Record[]; + }): Promise => { + const inputValues = inputs?.map((input) => input.input).join(", ") || ""; + const outputValues = outputs?.map((output) => output.foo).join(", ") || ""; + const referenceOutputValues = referenceOutputs + ?.map((ref) => ref?.output) + .join(", "); + + const avgDiff = + outputs?.reduce((sum, output, i) => { + return sum + Math.abs(output?.foo - referenceOutputs?.[i]?.output); + }, 0) || 0; + + return Promise.resolve({ + key: "OutputOnlySummaryEvaluator", + score: avgDiff === 0 ? 1 : 0, + comment: `Inputs: ${inputValues} Outputs: ${outputValues} ReferenceOutputs: ${referenceOutputValues} AvgDiff: ${avgDiff}`, + }); + }; + + // Run with multiple summary evaluators (both positional-signature variants) + // plus an object-style evaluator, and use maxConcurrency=1 to exercise the + // concurrency path. + const evalRes = await evaluate(targetFunc, { + data: TESTING_DATASET_NAME, + summaryEvaluators: [ + positionalSummaryEvaluator, + positionalSummaryEvaluator, + objectStyleSummaryEvaluator, + ], + maxConcurrency: 1, + description: + "evaluate works with summary evaluators in various configurations", + }); + + expect(evalRes.results).toHaveLength(2); + for (const result of evalRes.results) { + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + expect(result.run.outputs?.foo).toBeGreaterThanOrEqual(2); + expect(result.run.outputs?.foo).toBeLessThanOrEqual(3); + } + + const summaryResults = evalRes.summaryResults.results; + expect(summaryResults).toHaveLength(3); + + const allRuns = evalRes.results.map(({ run }) => run); + const allExamples = evalRes.results.map(({ example }) => example); + const runIds = allRuns.map(({ id }) => id).join(", "); + const exampleIds = allExamples.map(({ id }) => id).join(", "); + const expectedPositionalComment = `Runs: ${runIds} Examples: ${exampleIds}`; + + // Both positional summary evaluators produced identical feedback. + const positionalResults = summaryResults.filter( + (r) => r.key === "MyCustomScore", + ); + expect(positionalResults).toHaveLength(2); + for (const r of positionalResults) { + expect(r.score).toBe(1); + expect(r.comment).toBe(expectedPositionalComment); + } + + // Object-style summary evaluator received inputs/outputs/referenceOutputs. + const objectStyleResult = summaryResults.find( + (r) => r.key === "OutputOnlySummaryEvaluator", + ); + expect(objectStyleResult).toBeDefined(); + expect(typeof objectStyleResult?.score).toBe("number"); + + const allInputs = evalRes.results.map(({ example }) => example.inputs); + const allOutputs = evalRes.results.map(({ run }) => run.outputs); + const allReferenceOutputs = evalRes.results.map( + ({ example }) => example.outputs, + ); + const inputValues = allInputs.map((input) => input.input).join(", "); + const outputValues = allOutputs.map((output) => output?.foo).join(", "); + const referenceOutputValues = allReferenceOutputs + .map((ref) => ref?.output) + .join(", "); + const expectedAvgDiff = + allOutputs.reduce((sum, output, i) => { + return sum + Math.abs(output?.foo - allReferenceOutputs[i]?.output); + }, 0) / allOutputs.length; + expect(objectStyleResult?.comment).toBe( + `Inputs: ${inputValues} Outputs: ${outputValues} ReferenceOutputs: ${referenceOutputValues} AvgDiff: ${expectedAvgDiff}`, + ); +}); + +test("split info saved correctly", async () => { + const client = new Client(); + // create a new dataset + await client.createDataset(TESTING_DATASET_NAME2, { + description: + "For testing purposed. Is created & deleted for each test run.", + }); + // create examples + await client.createExamples({ + inputs: [{ input: 1 }, { input: 2 }, { input: 3 }], + outputs: [{ output: 2 }, { output: 3 }, { output: 4 }], + splits: [["test"], ["train"], ["validation", "test"]], + datasetName: TESTING_DATASET_NAME2, + }); + + const targetFunc = (input: Record) => { + return { + foo: input.input + 1, + }; + }; + await evaluate(targetFunc, { + data: client.listExamples({ datasetName: TESTING_DATASET_NAME2 }), + description: "splits info saved correctly", + }); + + const exp = client.listProjects({ + referenceDatasetName: TESTING_DATASET_NAME2, + }); + let myExp: TracerSession | null = null; + for await (const session of exp) { + myExp = session; + } + expect(myExp?.extra?.metadata?.dataset_splits.sort()).toEqual( + ["test", "train", "validation"].sort(), + ); + + await evaluate(targetFunc, { + data: client.listExamples({ + datasetName: TESTING_DATASET_NAME2, + splits: ["test"], + }), + description: "splits info saved correctly", + }); + + const exp2 = client.listProjects({ + referenceDatasetName: TESTING_DATASET_NAME2, + }); + let myExp2: TracerSession | null = null; + for await (const session of exp2) { + if (myExp2 === null || session.start_time > myExp2.start_time) { + myExp2 = session; + } + } + + expect(myExp2?.extra?.metadata?.dataset_splits.sort()).toEqual( + ["test", "validation"].sort(), + ); + + await evaluate(targetFunc, { + data: client.listExamples({ + datasetName: TESTING_DATASET_NAME2, + splits: ["train"], + }), + description: "splits info saved correctly", + }); + + const exp3 = client.listProjects({ + referenceDatasetName: TESTING_DATASET_NAME2, + }); + let myExp3: TracerSession | null = null; + for await (const session of exp3) { + if (myExp3 === null || session.start_time > myExp3.start_time) { + myExp3 = session; + } + } + + expect(myExp3?.extra?.metadata?.dataset_splits.sort()).toEqual( + ["train"].sort(), + ); +}); + +// Consolidated: covers maxConcurrency, multi-feedback-key evaluator return values, +// and async object-style evaluators running together. Previously split across +// "max concurrency works with custom evaluators", +// "evaluate accepts evaluators which return multiple feedback keys", and +// "evaluate handles async object-style evaluators". +test("evaluate supports concurrency limits and varied evaluator return types", async () => { + const targetFunc = (input: Record) => { + return { foo: input.input + 1 }; + }; + + // Evaluator that returns a single EvaluationResult (traditional signature). + const singleKeyEvaluator = (run: Run, example?: Example) => + Promise.resolve({ + key: "single", + score: 1, + comment: `Run: ${run.id} Example: ${example?.id}`, + }); + + // Evaluator that returns multiple feedback keys via an EvaluationResults object. + const multiKeyEvaluator = ( + run: Run, + example?: Example, + ): Promise => + Promise.resolve({ + results: [ + { + key: "first-key", + score: 1, + comment: `Run: ${run.id} Example: ${example?.id}`, + }, + { + key: "second-key", + score: 2, + comment: `Run: ${run.id} Example: ${example?.id}`, + }, + ], + }); + + // Async object-style evaluator receiving `{ outputs, referenceOutputs }`. + const asyncObjectEvaluator = async ({ + outputs, + referenceOutputs, + }: { + outputs?: Record; + referenceOutputs?: Record; + }) => { + await new Promise((resolve) => setTimeout(resolve, 10)); + return { + key: "async_evaluator", + score: outputs?.foo === referenceOutputs?.output ? 1 : 0, + }; + }; + + const evalRes = await evaluate(targetFunc, { + data: TESTING_DATASET_NAME, + evaluators: [singleKeyEvaluator, multiKeyEvaluator, asyncObjectEvaluator], + maxConcurrency: 1, + description: + "evaluate supports concurrency limits and varied evaluator return types", + }); + + expect(evalRes.results).toHaveLength(2); + + for (const result of evalRes.results) { + // 1 (single) + 2 (multi) + 1 (async object) = 4 feedback entries per example. + expect(result.evaluationResults.results).toHaveLength(4); + + const expectedComment = `Run: ${result.run.id} Example: ${result.example?.id}`; + + const single = result.evaluationResults.results.find( + (r) => r.key === "single", + ); + expect(single).toBeDefined(); + expect(single?.comment).toEqual(expectedComment); + + const firstKey = result.evaluationResults.results.find( + (r) => r.key === "first-key", + ); + expect(firstKey).toMatchObject({ + key: "first-key", + score: 1, + comment: expectedComment, + }); + + const secondKey = result.evaluationResults.results.find( + (r) => r.key === "second-key", + ); + expect(secondKey).toMatchObject({ + key: "second-key", + score: 2, + comment: expectedComment, + }); + + const asyncResult = result.evaluationResults.results.find( + (r) => r.key === "async_evaluator", + ); + expect(asyncResult).toBeDefined(); + expect(typeof asyncResult?.score).toBe("number"); + } +}); + +test("concurrent evaluate restores dataset order before summary", async () => { + const client = new Client(); + const exampleIterator = client.listExamples({ + datasetName: TESTING_DATASET_NAME, + }); + const examples: Example[] = []; + for await (const example of exampleIterator) { + examples.push(example); + } + + const orderedExamples = examples.sort( + (a, b) => Number(a.inputs.input) - Number(b.inputs.input), + ); + const expectedInputs = orderedExamples.map((example) => + Number(example.inputs.input), + ); + + const targetFunc = async (input: Record) => { + await new Promise((resolve) => + setTimeout(resolve, input.input === 1 ? 100 : 10), + ); + return { + foo: input.input + 1, + }; + }; + + const customEvaluator = async (run: Run, example?: Example) => { + await new Promise((resolve) => + setTimeout(resolve, Number(example?.inputs.input) === 1 ? 10 : 100), + ); + return { + key: "paired", + score: + run.outputs?.foo === Number(example?.inputs.input ?? Number.NaN) + 1 + ? 1 + : 0, + comment: `input:${example?.inputs.input}`, + }; + }; + + const inputOrderSummaryEvaluator = ({ + inputs, + }: { + inputs?: Record[]; + }): EvaluationResult => { + return { + key: "input_order", + score: 1, + comment: (inputs ?? []).map((input) => input.input).join(","), + }; + }; + + const evalRes = await evaluate(targetFunc, { + data: orderedExamples, + evaluators: [customEvaluator], + summaryEvaluators: [inputOrderSummaryEvaluator], + targetConcurrency: 2, + evaluationConcurrency: 2, + description: + "concurrent evaluate restores dataset order before summary integration test", + }); + + expect( + evalRes.results.map(({ example }) => Number(example.inputs.input)), + ).toEqual(expectedInputs); + expect(evalRes.results.map(({ run }) => run.outputs?.foo)).toEqual( + expectedInputs.map((input) => input + 1), + ); + expect( + evalRes.results.map( + ({ evaluationResults }) => evaluationResults.results[0].comment, + ), + ).toEqual(expectedInputs.map((input) => `input:${input}`)); + expect(evalRes.summaryResults.results[0].comment).toBe( + expectedInputs.join(","), + ); +}); + +requiresClickhouse.test( + "evaluate handles comparative target with ComparativeEvaluateOptions", + async () => { + const client = new Client(); + + // First, create two experiments to compare + const targetFunc1 = (input: Record) => { + return { + foo: input.input + 1, + }; + }; + + const targetFunc2 = (input: Record) => { + return { + foo: input.input + 2, + }; + }; + + // Run initial experiments + const exp1 = await evaluate(targetFunc1, { + data: TESTING_DATASET_NAME, + description: "First experiment for comparison", + }); + + const exp2 = await evaluate(targetFunc2, { + data: TESTING_DATASET_NAME, + description: "Second experiment for comparison", + }); + + await Promise.all( + [exp1, exp2].flatMap(({ results }) => + results.flatMap(({ run }) => waitUntilRunFound(client, run.id)), + ), + ); + // Create comparative evaluator + const comparativeEvaluator = ({ + runs, + example, + }: { + runs: Run[]; + example: Example; + }) => { + if (!runs || !example) throw new Error("Missing required parameters"); + + // Compare outputs from both runs + const scores = Object.fromEntries( + runs.map((run) => [ + run.id, + run.outputs?.foo === example.outputs?.output ? 1 : 0, + ]), + ); + + return { + key: "comparative_score", + scores, + }; + }; + + // Run comparative evaluation + const compareRes = await evaluate( + [exp1.experimentName, exp2.experimentName], + { + evaluators: [comparativeEvaluator], + description: "Comparative evaluation test", + randomizeOrder: true, + loadNested: false, + }, + ); + + // Verify we got ComparisonEvaluationResults + expect(compareRes.experimentName).toBeDefined(); + expect(compareRes.experimentName).toBeDefined(); + expect(compareRes.results).toBeDefined(); + expect(Array.isArray(compareRes.results)).toBe(true); + + // The pairwise comparison URL and comparative experiment are exposed on the result + expect(compareRes).toHaveProperty("url"); + expect(typeof compareRes.url === "string" || compareRes.url === null).toBe( + true, + ); + expect(compareRes.comparativeExperiment).toBeDefined(); + expect(compareRes.comparativeExperiment.id).toBeDefined(); + if (compareRes.url != null) { + expect(compareRes.url).toContain( + `comparativeExperiment=${compareRes.comparativeExperiment.id}`, + ); + } + + // Check structure of comparison results + for (const result of compareRes.results) { + expect(result.key).toBe("comparative_score"); + expect(result.scores).toBeDefined(); + expect(Object.keys(result.scores)).toHaveLength(2); // Should have scores for both experiments + } + }, +); + +test("evaluate enforces correct evaluator types for comparative evaluation at runtime", async () => { + const exp1 = await evaluate( + (input: Record) => ({ foo: input.input + 1 }), + { + data: TESTING_DATASET_NAME, + }, + ); + + const exp2 = await evaluate( + (input: Record) => ({ foo: input.input + 2 }), + { + data: TESTING_DATASET_NAME, + }, + ); + + // Create a standard evaluator (wrong type) + const standardEvaluator = (run: Run, example: Example) => ({ + key: "standard", + score: run.outputs?.foo === example.outputs?.output ? 1 : 0, + }); + + await expect( + // @ts-expect-error - Should error because standardEvaluator is not a ComparativeEvaluator + evaluate([exp1.experimentName, exp2.experimentName], { + evaluators: [standardEvaluator], + description: "Should fail at runtime", + }), + ).rejects.toThrow(); // You might want to be more specific about the error message +}); + +test("evaluate succeeds with child runs that take a while to resolve", async () => { + const target = async () => { + void generateText({ + prompt: "Hello world", + model: openai("gpt-5-nano"), + }); + return { foo: "foo" }; + }; + const res = await evaluate(target, { + data: TESTING_DATASET_NAME, + }); + expect(res.results.length).toEqual(2); + for (const result of res.results) { + // This check is important to ensure the output is set before child promises resolve + // for AI SDK + expect(result.run.outputs).toEqual({ foo: "foo" }); + } +}); diff --git a/src/lib/tests/evaluate_attachments.int.test.ts b/src/lib/tests/evaluate_attachments.int.test.ts new file mode 100644 index 0000000..31dcd65 --- /dev/null +++ b/src/lib/tests/evaluate_attachments.int.test.ts @@ -0,0 +1,468 @@ +import { evaluate, TargetConfigT } from "../evaluation/_runner.js"; +import { ExampleUploadWithAttachments } from "../schemas.js"; +import { Client } from "../index.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { RunnableLambda } from "@langchain/core/runnables"; + +function arraysEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +test("evaluate can handle examples with attachments", async () => { + const client = new Client(); + const datasetName = `test_dataset_attachments_${uuidv4()}`; + const dataset = await client.createDataset(datasetName); + + // Create examples with attachments + const example: ExampleUploadWithAttachments = { + inputs: { question: "What is shown in the image?" }, + outputs: { answer: "test image" }, + attachments: { + image: ["image/png", Buffer.from("fake image data for testing")], + }, + }; + + await client.uploadExamplesMultipart(dataset.id, [example]); + + // Define target function that processes attachments + const targetFunc = async ( + _inputs: Record, + config?: TargetConfigT, + ) => { + // Verify we receive the attachment data + if (!config?.attachments?.image) { + throw new Error("Image attachment not found"); + } + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentMimeType = config?.attachments?.image.mime_type; + if (attachmentMimeType !== "image/png") { + throw new Error("Image attachment has incorrect mime type"); + } + const attachmentData: Uint8Array | undefined = config?.attachments?.[ + "image" + ].presigned_url + ? new Uint8Array( + (await fetch(config?.attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { answer: "test image" }; + }; + + const customEvaluator = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeDefined(); + expect(attachments?.image).toBeDefined(); + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentMimeType = attachments?.image.mime_type; + if (attachmentMimeType !== "image/png") { + throw new Error("Image attachment has incorrect mime type"); + } + const attachmentData: Uint8Array | undefined = attachments?.image + .presigned_url + ? new Uint8Array( + (await fetch(attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { + key: "key", + score: 1, + }; + }; + + // Run evaluation + const evalRes = await evaluate(targetFunc, { + data: datasetName, + description: "Testing attachment handling in evaluation", + client: client, + evaluators: [customEvaluator], + numRepetitions: 2, + includeAttachments: true, + }); + + // Verify results + expect(evalRes.results).toHaveLength(2); + + for (const result of evalRes.results) { + // TODO: Uncomment when attachments are traced correctly + // expect(result.run.attachments).toBeDefined(); + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + + // Verify evaluator results + const evalResults = result.evaluationResults.results; + expect(evalResults).toHaveLength(1); // Should have attachment_presence and attachment_processing results + + // Check that attachments were properly processed + const evalResult = evalResults.find((r) => r.key === "key"); + expect(evalResult?.score).toBe(1); + } + + // Cleanup + await client.deleteDataset({ datasetName }); +}); + +test("evaluate with attachments not in target function", async () => { + const client = new Client(); + const datasetName = `test_dataset_attachments_${uuidv4()}`; + const dataset = await client.createDataset(datasetName); + + // Create examples with attachments + const example: ExampleUploadWithAttachments = { + inputs: { question: "What is shown in the image?" }, + outputs: { answer: "test image" }, + attachments: { + image: ["image/png", Buffer.from("fake image data for testing")], + }, + }; + + await client.uploadExamplesMultipart(dataset.id, [example]); + + // Define target function that processes attachments + const targetFunc = async (_inputs: Record) => { + return { answer: "test image" }; + }; + + const customEvaluator = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeDefined(); + expect(attachments?.image).toBeDefined(); + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = attachments?.image + .presigned_url + ? new Uint8Array( + (await fetch(attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { + key: "key", + score: 1, + }; + }; + + // Run evaluation + const evalRes = await evaluate(targetFunc, { + data: datasetName, + description: "Testing attachment handling in evaluation", + client: client, + evaluators: [customEvaluator], + numRepetitions: 2, + includeAttachments: true, + }); + + // Verify results + expect(evalRes.results).toHaveLength(2); + + for (const result of evalRes.results) { + // TODO: Uncomment when attachments are traced correctly + // expect(result.run.attachments).toBeDefined(); + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + + // Verify evaluator results + const evalResults = result.evaluationResults.results; + expect(evalResults).toHaveLength(1); // Should have attachment_presence and attachment_processing results + + // Check that attachments were properly processed + const evalResult = evalResults.find((r) => r.key === "key"); + expect(evalResult?.score).toBe(1); + } + + // Cleanup + await client.deleteDataset({ datasetName }); +}); + +test("multiple evaluators with attachments", async () => { + const client = new Client(); + const datasetName = `test_dataset_attachments_${uuidv4()}`; + const dataset = await client.createDataset(datasetName); + + // Create examples with attachments + const example: ExampleUploadWithAttachments = { + inputs: { question: "What is shown in the image?" }, + outputs: { answer: "test image" }, + attachments: { + image: ["image/png", Buffer.from("fake image data for testing")], + }, + }; + + await client.uploadExamplesMultipart(dataset.id, [example]); + + // Define target function that processes attachments + const targetFunc = async ( + _inputs: Record, + config?: TargetConfigT, + ) => { + // Verify we receive the attachment data + if (!config?.attachments?.image) { + throw new Error("Image attachment not found"); + } + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = config?.attachments?.[ + "image" + ].presigned_url + ? new Uint8Array( + (await fetch(config?.attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { answer: "test image" }; + }; + + const customEvaluatorOne = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeDefined(); + expect(attachments?.image).toBeDefined(); + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = attachments?.image + .presigned_url + ? new Uint8Array( + (await fetch(attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { + key: "key1", + score: 1, + }; + }; + + const customEvaluatorTwo = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeDefined(); + expect(attachments?.image).toBeDefined(); + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = attachments?.image + .presigned_url + ? new Uint8Array( + (await fetch(attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { + key: "key2", + score: 1, + }; + }; + + // Run evaluation + const evalRes = await evaluate(targetFunc, { + data: datasetName, + description: "Testing attachment handling in evaluation", + client: client, + evaluators: [customEvaluatorOne, customEvaluatorTwo], + numRepetitions: 2, + includeAttachments: true, + }); + + // Verify results + expect(evalRes.results).toHaveLength(2); + + for (const result of evalRes.results) { + // TODO: Uncomment when attachments are traced correctly + // expect(result.run.attachments).toBeDefined(); + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + + // Verify evaluator results + const evalResults = result.evaluationResults.results; + expect(evalResults).toHaveLength(2); // Should have attachment_presence and attachment_processing results + + // Check that attachments were properly processed + const evalResult1 = evalResults.find((r) => r.key === "key1"); + expect(evalResult1?.score).toBe(1); + const evalResult2 = evalResults.find((r) => r.key === "key2"); + expect(evalResult2?.score).toBe(1); + } + + // Cleanup + await client.deleteDataset({ datasetName }); +}); + +test("evaluate with attachments runnable target function", async () => { + const client = new Client(); + const datasetName = `test_dataset_attachments_${uuidv4()}`; + const dataset = await client.createDataset(datasetName); + + // Create examples with attachments + const example: ExampleUploadWithAttachments = { + inputs: { question: "What is shown in the image?" }, + outputs: { answer: "test image" }, + attachments: { + image: ["image/png", Buffer.from("fake image data for testing")], + }, + }; + + await client.uploadExamplesMultipart(dataset.id, [example]); + + const myFunction = async (_input: any, config?: any) => { + if (!config?.attachments?.image) { + throw new Error("Image attachment not found"); + } + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = config?.attachments?.[ + "image" + ].presigned_url + ? new Uint8Array( + (await fetch(config?.attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { answer: "test image" }; + }; + + // Define target function that processes attachments + const targetFunc = RunnableLambda.from(myFunction); + + const customEvaluator = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeDefined(); + expect(attachments?.image).toBeDefined(); + const expectedData = new Uint8Array( + Buffer.from("fake image data for testing"), + ); + const attachmentData: Uint8Array | undefined = attachments?.image + .presigned_url + ? new Uint8Array( + (await fetch(attachments?.image.presigned_url).then((res) => + res.arrayBuffer(), + )) as ArrayBuffer, + ) + : undefined; + if (!arraysEqual(attachmentData ?? new Uint8Array(), expectedData)) { + throw new Error("Image data does not match expected data"); + } + return { + key: "key", + score: 1, + }; + }; + + // Run evaluation + const evalRes = await evaluate(targetFunc, { + data: datasetName, + description: "Testing attachment handling in evaluation", + client: client, + evaluators: [customEvaluator], + numRepetitions: 2, + includeAttachments: true, + }); + + // Verify results + expect(evalRes.results).toHaveLength(2); + + for (const result of evalRes.results) { + // TODO: Uncomment when attachments are traced correctly + // expect(result.run.attachments).toBeDefined(); + expect(result.run).toBeDefined(); + expect(result.example).toBeDefined(); + expect(result.evaluationResults).toBeDefined(); + + // Verify evaluator results + const evalResults = result.evaluationResults.results; + expect(evalResults).toHaveLength(1); // Should have attachment_presence and attachment_processing results + + // Check that attachments were properly processed + const evalResult = evalResults.find((r) => r.key === "key"); + expect(evalResult?.score).toBe(1); + } + + // Cleanup + await client.deleteDataset({ datasetName }); +}); + +test("attachments don't appear without includeAttachments", async () => { + const client = new Client(); + const datasetName = `test_dataset_attachments_${uuidv4()}`; + const dataset = await client.createDataset(datasetName); + + // Create examples with attachments + const example: ExampleUploadWithAttachments = { + inputs: { question: "What is shown in the image?" }, + outputs: { answer: "test image" }, + attachments: { + image: ["image/png", Buffer.from("fake image data for testing")], + }, + }; + + await client.uploadExamplesMultipart(dataset.id, [example]); + + const myFunction = async (_input: any, config?: any) => { + if (config?.attachments) { + throw new Error("Attachments should not exist!"); + } + return { answer: "test image" }; + }; + + // Define target function that processes attachments + const targetFunc = RunnableLambda.from(myFunction); + + const customEvaluator = async ({ attachments }: { attachments?: any }) => { + expect(attachments).toBeUndefined(); + return { + key: "key", + score: 1, + }; + }; + + // Run evaluation + const evalRes = await evaluate(targetFunc, { + data: datasetName, + description: "Testing attachment handling in evaluation", + client: client, + evaluators: [customEvaluator], + numRepetitions: 2, + }); + + // Verify results + expect(evalRes.results).toHaveLength(2); + + // Cleanup + await client.deleteDataset({ datasetName }); +}); diff --git a/src/lib/tests/evaluate_runner.test.ts b/src/lib/tests/evaluate_runner.test.ts new file mode 100644 index 0000000..6b0dc35 --- /dev/null +++ b/src/lib/tests/evaluate_runner.test.ts @@ -0,0 +1,1078 @@ +import { jest } from "@jest/globals"; +import type { Run as V2Run } from "../../resources/runs/runs.js"; +import type { Client } from "../client.js"; +import { + _collectEvaluatorKeys, + _extractEvaluatorFeedbackKeys, + _mapWithConcurrency, + _reorderResultRowsByExampleIndex, + evaluate, +} from "../evaluation/_runner.js"; +import { loadTracesForExperiment } from "../evaluation/evaluate_comparative.js"; +import { getCurrentRunTree } from "../singletons/traceable.js"; +import { PQueue } from "../utils/p-queue.js"; +import { Example, Run, TracerSession } from "../schemas.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function* fromArray(items: T[]): AsyncGenerator { + for (const item of items) { + yield item; + } +} + +async function collect(iterable: AsyncIterable): Promise { + const results: T[] = []; + for await (const item of iterable) { + results.push(item); + } + return results; +} + +describe("evaluation runner internals", () => { + test("mapWithConcurrency with concurrency Infinity runs all tasks at once", async () => { + let active = 0; + let maxActive = 0; + const queue = new PQueue({ concurrency: Infinity }); + + const output = await collect( + _mapWithConcurrency(fromArray([1, 2, 3, 4, 5]), queue, async (value) => { + active += 1; + maxActive = Math.max(maxActive, active); + await sleep(10); + active -= 1; + return value; + }), + ); + + expect(output).toHaveLength(5); + expect(maxActive).toBe(5); // All 5 should run concurrently + }); + + test("mapWithConcurrency with concurrency 1 runs tasks one at a time", async () => { + let active = 0; + let maxActive = 0; + const queue = new PQueue({ concurrency: 1 }); + + const output = await collect( + _mapWithConcurrency(fromArray([1, 2, 3]), queue, async (value) => { + active += 1; + maxActive = Math.max(maxActive, active); + await sleep(10); + active -= 1; + return value; + }), + ); + + expect(output).toEqual([1, 2, 3]); + expect(maxActive).toBe(1); + }); + + test("mapWithConcurrency yields by completion order and honors concurrency", async () => { + let active = 0; + let maxActive = 0; + const delays: Record = { + 1: 30, + 2: 5, + 3: 10, + }; + + const queue = new PQueue({ concurrency: 2 }); + const output = await collect( + _mapWithConcurrency(fromArray([1, 2, 3]), queue, async (value) => { + active += 1; + maxActive = Math.max(maxActive, active); + await sleep(delays[value]); + active -= 1; + return value; + }), + ); + + expect(output).toEqual([2, 3, 1]); + expect(maxActive).toBeLessThanOrEqual(2); + expect(maxActive).toBe(2); + }); + + test("mapWithConcurrency propagates mapper errors", async () => { + const queue = new PQueue({ concurrency: 2 }); + await expect( + collect( + _mapWithConcurrency(fromArray([1, 2, 3]), queue, async (value) => { + if (value === 2) { + throw new Error("mapper boom"); + } + await sleep(1); + return value; + }), + ), + ).rejects.toThrow("mapper boom"); + }); + + test("mapWithConcurrency propagates source iterable errors", async () => { + async function* source(): AsyncGenerator { + yield 1; + throw new Error("source boom"); + } + + const queue = new PQueue({ concurrency: 2 }); + await expect( + collect(_mapWithConcurrency(source(), queue, async (value) => value)), + ).rejects.toThrow("source boom"); + }); + + test("evaluate supplies the experiment ID when logging run feedback", async () => { + const experimentId = "00000000-0000-0000-0000-000000000004"; + const datasetId = "00000000-0000-0000-0000-000000000000"; + const now = new Date().toISOString(); + const feedbackCalls: any[][] = []; + const mockClient = { + createProject: async () => ({ + id: experimentId, + name: "test-project", + reference_dataset_id: datasetId, + }), + updateProject: async () => ({}), + logEvaluationFeedback: async (...args: any[]) => { + feedbackCalls.push(args); + return []; + }, + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(async () => ({ output: "ok" }), { + data: [ + { + id: "00000000-0000-0000-0000-000000000001", + inputs: { input: "hello" }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ], + evaluators: [async () => ({ key: "quality", score: 1 })], + client: mockClient, + }); + for await (const _ of results) { + // Drain the result stream. + } + + expect(feedbackCalls).toHaveLength(1); + expect(feedbackCalls[0]).toEqual([ + expect.objectContaining({ + run: expect.objectContaining({ start_time: expect.anything() }), + projectId: experimentId, + }), + ]); + }); + + test.each([true, false])( + "evaluate with disableEvaluatorTracing=%s controls evaluator tracing and feedback links", + async (disableEvaluatorTracing) => { + const experimentId = "00000000-0000-0000-0000-000000000004"; + const datasetId = "00000000-0000-0000-0000-000000000000"; + const now = new Date().toISOString(); + const feedbackCalls: any[] = []; + const summaryFeedbackCalls: any[] = []; + const mockClient = { + createProject: async () => ({ + id: experimentId, + name: "test-project", + reference_dataset_id: datasetId, + }), + updateProject: async () => ({}), + logEvaluationFeedback: async (args: any) => { + feedbackCalls.push(args); + return []; + }, + _selectEvalResults: (results: any) => + "results" in results ? results.results : [results], + createFeedback: async (...args: any[]) => { + summaryFeedbackCalls.push(args); + return {}; + }, + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(async () => ({ output: "ok" }), { + data: [ + { + id: "00000000-0000-0000-0000-000000000001", + inputs: { input: "hello" }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ], + evaluators: [async () => ({ key: "quality", score: 1 })], + summaryEvaluators: [async () => ({ key: "count", score: 1 })], + client: mockClient, + disableEvaluatorTracing, + }); + for await (const _ of results) { + // Drain the result stream. + } + + // Feedback is created either way. + expect(feedbackCalls).toHaveLength(1); + expect(summaryFeedbackCalls).toHaveLength(1); + + // The evaluator-run link is present only when evaluators are traced. + const response = feedbackCalls[0].evaluatorResponse; + const sourceRunId = + "results" in response + ? response.results[0].sourceRunId + : response.sourceRunId; + if (disableEvaluatorTracing) { + expect(sourceRunId).toBeUndefined(); + } else { + expect(sourceRunId).toEqual(expect.any(String)); + } + }, + ); + + test("evaluate leaves evaluator tracing on by default", async () => { + const experimentId = "00000000-0000-0000-0000-000000000004"; + const datasetId = "00000000-0000-0000-0000-000000000000"; + const now = new Date().toISOString(); + const feedbackCalls: any[] = []; + const mockClient = { + createProject: async () => ({ + id: experimentId, + name: "test-project", + reference_dataset_id: datasetId, + }), + updateProject: async () => ({}), + logEvaluationFeedback: async (args: any) => { + feedbackCalls.push(args); + return []; + }, + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(async () => ({ output: "ok" }), { + data: [ + { + id: "00000000-0000-0000-0000-000000000001", + inputs: { input: "hello" }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ], + evaluators: [async () => ({ key: "quality", score: 1 })], + client: mockClient, + }); + for await (const _ of results) { + // Drain the result stream. + } + + const response = feedbackCalls[0].evaluatorResponse; + const sourceRunId = + "results" in response + ? response.results[0].sourceRunId + : response.sourceRunId; + expect(sourceRunId).toEqual(expect.any(String)); + }); + + test.each([ + // [option value, expected tracingEnabled override on the summary evaluator] + [undefined, undefined], + [true, false], + ])( + "evaluate with disableEvaluatorTracing=%s sets the summary evaluator tracing override to %s", + async (disableEvaluatorTracing, expectedOverride) => { + const experimentId = "00000000-0000-0000-0000-000000000004"; + const datasetId = "00000000-0000-0000-0000-000000000000"; + const now = new Date().toISOString(); + let override: boolean | undefined; + const mockClient = { + createProject: async () => ({ + id: experimentId, + name: "test-project", + reference_dataset_id: datasetId, + }), + updateProject: async () => ({}), + createRun: async () => undefined, + updateRun: async () => undefined, + _selectEvalResults: (results: any) => + "results" in results ? results.results : [results], + createFeedback: async () => ({}), + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(async () => ({ output: "ok" }), { + data: [ + { + id: "00000000-0000-0000-0000-000000000001", + inputs: { input: "hello" }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ], + summaryEvaluators: [ + async () => { + override = getCurrentRunTree(true)?.tracingEnabled; + return { key: "count", score: 1 }; + }, + ], + client: mockClient, + disableEvaluatorTracing, + }); + for await (const _ of results) { + // Drain the result stream. + } + + // Omitting the option must leave the override unset so the environment + // still decides. Forcing `true` here would switch tracing on for callers + // who have it turned off. + expect(override).toBe(expectedOverride); + }, + ); + + test("evaluate resolves existing runs by session_id and logs routing fields", async () => { + const experimentId = "00000000-0000-0000-0000-000000000004"; + const datasetId = "00000000-0000-0000-0000-000000000000"; + const exampleId = "00000000-0000-0000-0000-000000000001"; + const runId = "00000000-0000-0000-0000-000000000002"; + const startTime = "2026-07-21T12:34:56.789Z"; + const readProject = jest.fn(async () => ({ + id: experimentId, + name: "existing-project", + reference_dataset_id: datasetId, + })); + const logEvaluationFeedback = jest.fn(async () => []); + const mockClient = { + readProject, + logEvaluationFeedback, + getDatasetUrl: async () => "http://test.com", + createRun: async () => undefined, + updateRun: async () => undefined, + awaitPendingTraceBatches: async () => undefined, + } as any; + const run = { + id: runId, + name: "existing-run", + run_type: "chain", + start_time: startTime, + trace_id: runId, + dotted_order: "", + session_id: experimentId, + reference_example_id: exampleId, + inputs: { input: "hello" }, + outputs: { output: "ok" }, + } as Run; + const now = new Date().toISOString(); + + const results = await evaluate(fromArray([run]) as never, { + data: [ + { + id: exampleId, + inputs: { input: "hello" }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ], + evaluators: [async () => ({ key: "quality", score: 1 })], + client: mockClient, + }); + for await (const _ of results) { + // Drain the result stream. + } + + expect(readProject).toHaveBeenCalledWith({ projectId: experimentId }); + expect(logEvaluationFeedback).toHaveBeenCalledWith( + expect.objectContaining({ + run: expect.objectContaining({ + session_id: experimentId, + start_time: startTime, + }), + projectId: experimentId, + }), + ); + }); + + test("reorderResultRowsByExampleIndex restores original example order", () => { + const inputRows = [ + { + exampleIndex: 2, + run: { id: "run-2" }, + example: { id: "example-2" }, + evaluationResults: { results: [{ key: "key-2" }] }, + }, + { + exampleIndex: 0, + run: { id: "run-0" }, + example: { id: "example-0" }, + evaluationResults: { results: [{ key: "key-0" }] }, + }, + { + exampleIndex: 1, + run: { id: "run-1" }, + example: { id: "example-1" }, + evaluationResults: { results: [{ key: "key-1" }] }, + }, + ] as unknown as Parameters[0]; + + const { orderedRows, orderedRuns } = + _reorderResultRowsByExampleIndex(inputRows); + + expect(orderedRows.map((row) => row.example.id)).toEqual([ + "example-0", + "example-1", + "example-2", + ]); + expect( + orderedRows.map((row) => row.evaluationResults.results[0].key), + ).toEqual(["key-0", "key-1", "key-2"]); + expect(orderedRuns.map((run) => run.id)).toEqual([ + "run-0", + "run-1", + "run-2", + ]); + expect(inputRows.map((row) => row.exampleIndex)).toEqual([2, 0, 1]); + }); + + test("reorderResultRowsByExampleIndex keeps stable order for ties", () => { + const inputRows = [ + { + exampleIndex: 1, + run: { id: "run-a" }, + example: { id: "example-a" }, + evaluationResults: { results: [] }, + }, + { + exampleIndex: 1, + run: { id: "run-b" }, + example: { id: "example-b" }, + evaluationResults: { results: [] }, + }, + { + exampleIndex: 0, + run: { id: "run-c" }, + example: { id: "example-c" }, + evaluationResults: { results: [] }, + }, + ] as unknown as Parameters[0]; + + const { orderedRuns } = _reorderResultRowsByExampleIndex(inputRows); + expect(orderedRuns.map((run) => run.id)).toEqual([ + "run-c", + "run-a", + "run-b", + ]); + }); + + test("fast results can be processed while slow tasks are still running", async () => { + const queue = new PQueue({ concurrency: 3 }); + const delays: Record = { + 1: 100, // slow + 2: 10, // fast + 3: 10, // fast + 4: 10, // fast + }; + + const resultsOrder: number[] = []; + const startTime = Date.now(); + + for await (const value of _mapWithConcurrency( + fromArray([1, 2, 3, 4]), + queue, + async (value) => { + await sleep(delays[value]); + return value; + }, + )) { + resultsOrder.push(value); + const elapsed = Date.now() - startTime; + + // If we got value 2, 3, or 4, task 1 (100ms) should still be running + if ([2, 3, 4].includes(value)) { + expect(elapsed).toBeLessThan(100); + } + } + + // Fast tasks (2, 3, 4) should complete before slow task (1) + expect(resultsOrder.slice(0, 3)).toContain(2); + expect(resultsOrder.slice(0, 3)).toContain(3); + expect(resultsOrder.slice(0, 3)).toContain(4); + expect(resultsOrder[3]).toBe(1); // Slow task completes last + }); + + test("separate queues allow independent concurrency control", async () => { + let maxTargetActive = 0; + let currentTargetActive = 0; + let maxEvalActive = 0; + let currentEvalActive = 0; + + const now = new Date().toISOString(); + const examples: Example[] = [ + { + id: "1", + inputs: { value: 1 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "2", + inputs: { value: 2 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "3", + inputs: { value: 3 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + ]; + + const target = async (input: { value: number }) => { + currentTargetActive++; + maxTargetActive = Math.max(maxTargetActive, currentTargetActive); + await sleep(10); + currentTargetActive--; + return { result: input.value }; + }; + + const evaluator = async () => { + currentEvalActive++; + maxEvalActive = Math.max(maxEvalActive, currentEvalActive); + await sleep(10); + currentEvalActive--; + return { key: "test", score: 1 }; + }; + + const mockClient = { + createProject: async () => ({ + id: "test", + name: "test", + reference_dataset_id: "test", + }), + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(target, { + data: examples, + evaluators: [evaluator], + targetConcurrency: 1, + evaluationConcurrency: 2, + client: mockClient, + }); + + const allResults = []; + for await (const result of results) { + allResults.push(result); + } + + // Target should have max concurrency of 1 + expect(maxTargetActive).toBe(1); + // Evaluator should have max concurrency of 2 + expect(maxEvalActive).toBe(2); + }, 10000); + + test("end-to-end: evaluations run on fast predictions before slow predictions finish", async () => { + const delays: Record = { + 0: 100, // slow prediction + 1: 10, // fast prediction + 2: 10, // fast prediction + }; + + const datasetId = "00000000-0000-0000-0000-000000000000"; + const now = new Date().toISOString(); + const examples: Example[] = [ + { + id: "00000000-0000-0000-0000-000000000001", + inputs: { value: 0 }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "00000000-0000-0000-0000-000000000002", + inputs: { value: 1 }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "00000000-0000-0000-0000-000000000003", + inputs: { value: 2 }, + outputs: {}, + dataset_id: datasetId, + created_at: now, + modified_at: now, + runs: [], + }, + ]; + + const predictionTimes: Record = {}; + const evaluationTimes: Record = {}; + const startTime = Date.now(); + + // Target function with varying delays + const target = async (input: { value: number }) => { + await sleep(delays[input.value]); + predictionTimes[input.value] = Date.now() - startTime; + return { result: input.value }; + }; + + // Evaluator that records when it runs + const evaluator = async ({ example }: any) => { + evaluationTimes[example.inputs.value] = Date.now() - startTime; + return { key: "test", score: 1 }; + }; + + // Mock client to avoid real API calls + const mockClient = { + createProject: async () => ({ + id: "00000000-0000-0000-0000-000000000004", + name: "test-project", + reference_dataset_id: datasetId, + }), + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(target, { + data: examples, + evaluators: [evaluator], + targetConcurrency: 3, + evaluationConcurrency: 3, + client: mockClient, + }); + + // Wait for all results to be processed + const allResults = []; + for await (const result of results) { + allResults.push(result); + } + + // Fast predictions (1, 2) should complete and be evaluated before slow prediction (0) finishes + expect(predictionTimes[1]).toBeLessThan(predictionTimes[0]); + expect(predictionTimes[2]).toBeLessThan(predictionTimes[0]); + + // Fast predictions should be evaluated before slow prediction finishes + expect(evaluationTimes[1]).toBeLessThan(predictionTimes[0]); + expect(evaluationTimes[2]).toBeLessThan(predictionTimes[0]); + + // All evaluations should have run + expect(Object.keys(evaluationTimes)).toHaveLength(3); + }, 10000); + + test("maxConcurrency=0 runs tasks sequentially (matching Python behavior)", async () => { + const executionOrder: number[] = []; + let currentlyExecuting: number | null = null; + + const now = new Date().toISOString(); + const examples: Example[] = [ + { + id: "1", + inputs: { value: 1 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "2", + inputs: { value: 2 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + { + id: "3", + inputs: { value: 3 }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + }, + ]; + + const target = async (input: { value: number }) => { + // Verify no other task is executing + expect(currentlyExecuting).toBeNull(); + currentlyExecuting = input.value; + executionOrder.push(input.value); + await sleep(10); + currentlyExecuting = null; + return { result: input.value }; + }; + + const mockClient = { + createProject: async () => ({ + id: "test", + name: "test", + reference_dataset_id: "test", + }), + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate(target, { + data: examples, + evaluators: [], + maxConcurrency: 0, // Sequential execution + client: mockClient, + }); + + const allResults = []; + for await (const result of results) { + allResults.push(result); + } + + // Verify sequential execution - order should match input order + expect(executionOrder).toEqual([1, 2, 3]); + }, 10000); + + test("evaluate infers numExamples from list data", async () => { + const now = new Date().toISOString(); + const examples: Example[] = [1, 2, 3].map((v) => ({ + id: `e${v}`, + inputs: { value: v }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + })); + + const createProjectCalls: any[] = []; + const mockClient = { + createProject: async (params: any) => { + createProjectCalls.push(params); + return { id: "test", name: "test", reference_dataset_id: "test" }; + }, + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate( + async (input: any) => ({ result: input.value }), + { + data: examples, + evaluators: [], + numRepetitions: 4, + client: mockClient, + }, + ); + const collected = []; + for await (const r of results) { + collected.push(r); + } + + expect(createProjectCalls).toHaveLength(1); + expect(createProjectCalls[0].numExamples).toBe(3); + expect(createProjectCalls[0].numRepetitions).toBe(4); + }); + + test("evaluate omits numExamples for async-iterable data", async () => { + const now = new Date().toISOString(); + const examples: Example[] = [1, 2, 3].map((v) => ({ + id: `e${v}`, + inputs: { value: v }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + })); + + async function* exampleGen() { + for (const e of examples) yield e; + } + + const createProjectCalls: any[] = []; + const mockClient = { + createProject: async (params: any) => { + createProjectCalls.push(params); + return { id: "test", name: "test", reference_dataset_id: "test" }; + }, + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate( + async (input: any) => ({ result: input.value }), + { + data: exampleGen(), + evaluators: [], + client: mockClient, + }, + ); + for await (const _ of results) { + // drain + } + + expect(createProjectCalls).toHaveLength(1); + expect(createProjectCalls[0].numExamples).toBeNull(); + expect(createProjectCalls[0].numRepetitions).toBe(1); + }); +}); + +describe("evaluator feedback-key extraction", () => { + test("prefers the returned key over the function name", () => { + // The bug: function name (binding) != returned feedback key. + const completenessEvaluator = async ({ + outputs, + }: { + outputs?: Record; + }) => ({ + key: "field_completeness", + score: Object.keys(outputs ?? {}).length, + }); + expect(_extractEvaluatorFeedbackKeys(completenessEvaluator as any)).toEqual( + ["field_completeness"], + ); + }); + + test("named function literal key", () => { + function relevance({ outputs }: { outputs?: Record }) { + return { key: "answer_relevance", score: outputs ? 1 : 0 }; + } + expect(_extractEvaluatorFeedbackKeys(relevance as any)).toEqual([ + "answer_relevance", + ]); + }); + + test("multi-result evaluator returns all keys", () => { + const multi = ({ outputs }: { outputs?: Record }) => ({ + results: [ + { key: "k1", score: outputs ? 1 : 0 }, + { key: "k2", score: 0 }, + ], + }); + expect(_extractEvaluatorFeedbackKeys(multi as any)).toEqual(["k1", "k2"]); + }); + + test("falls back to the function name when the key is dynamic", () => { + const dynamicKey = "computed_at_runtime"; + function dynamicEval({ outputs }: { outputs?: Record }) { + return { key: dynamicKey, score: outputs ? 1 : 0 }; + } + expect(_extractEvaluatorFeedbackKeys(dynamicEval as any)).toEqual([ + "dynamicEval", + ]); + }); + + test("returns empty when neither a literal key nor a name is available", () => { + const runtimeKey = "x"; + expect( + _extractEvaluatorFeedbackKeys(((_args: any) => ({ + key: runtimeKey, + score: 1, + })) as any), + ).toEqual([]); + }); + + test("_collectEvaluatorKeys aggregates across evaluators", () => { + const completenessEvaluator = async ({ + outputs, + }: { + outputs?: Record; + }) => ({ key: "field_completeness", score: outputs ? 1 : 0 }); + const qualityEvaluator = async ({ + outputs, + }: { + outputs?: Record; + }) => ({ key: "analysis_quality", score: outputs ? 1 : 0 }); + expect( + _collectEvaluatorKeys([ + completenessEvaluator as any, + qualityEvaluator as any, + ]), + ).toEqual(["field_completeness", "analysis_quality"]); + }); + + test("createProject receives the returned feedback keys, not function names", async () => { + const now = new Date().toISOString(); + const examples: Example[] = [1, 2, 3].map((v) => ({ + id: `e${v}`, + inputs: { value: v }, + outputs: {}, + dataset_id: "test", + created_at: now, + modified_at: now, + runs: [], + })); + + const completenessEvaluator = async ({ + outputs, + }: { + outputs?: Record; + }) => ({ key: "field_completeness", score: outputs ? 1 : 0 }); + const qualityEvaluator = async ({ + outputs, + }: { + outputs?: Record; + }) => ({ key: "analysis_quality", score: outputs ? 1 : 0 }); + + const createProjectCalls: any[] = []; + const mockClient = { + createProject: async (params: any) => { + createProjectCalls.push(params); + return { id: "test", name: "test", reference_dataset_id: "test" }; + }, + updateProject: async () => ({}), + createFeedback: async () => ({}), + logEvaluationFeedback: async () => [], + awaitPendingTraceBatches: async () => undefined, + getDatasetUrl: async () => "http://test.com", + } as any; + + const results = await evaluate( + async (input: any) => ({ result: input.value }), + { + data: examples, + evaluators: [completenessEvaluator, qualityEvaluator], + client: mockClient, + }, + ); + for await (const _ of results) { + // drain + } + + expect(createProjectCalls).toHaveLength(1); + expect(createProjectCalls[0].evaluatorKeys).toEqual([ + "field_completeness", + "analysis_quality", + ]); + }); +}); + +describe("loadTracesForExperiment", () => { + const project: TracerSession = { + id: "00000000-0000-0000-0000-000000000001", + tenant_id: "00000000-0000-0000-0000-000000000002", + start_time: 0, + end_time: 1_000, + }; + + const v2Run: V2Run = { + id: "00000000-0000-0000-0000-000000000003", + name: "root", + run_type: "CHAIN", + start_time: "1970-01-01T00:00:00.000Z", + trace_id: "00000000-0000-0000-0000-000000000003", + project_id: project.id, + parent_run_ids: [], + reference_example_id: "00000000-0000-0000-0000-000000000004", + inputs: { question: "test" }, + outputs: { answer: "test" }, + status: "SUCCESS", + }; + + test("loads runs from v2 when SDB querying is enabled", async () => { + const queryV2 = jest.fn(() => fromArray([v2Run])); + const listRuns = jest.fn(); + const client = { + _supportsSDBQuery: async () => true, + runs: { queryV2 }, + _listRuns: listRuns, + } as unknown as Client; + + const runs = await loadTracesForExperiment(client, project, { + loadNested: false, + }); + + expect(runs).toHaveLength(1); + expect(runs[0].session_id).toBe(project.id); + expect(queryV2).toHaveBeenCalledWith( + expect.objectContaining({ + project_ids: [project.id], + min_start_time: "1970-01-01T00:00:00.000Z", + max_start_time: "1970-01-01T00:00:01.000Z", + is_root: true, + }), + ); + expect(listRuns).not.toHaveBeenCalled(); + }); + + test("loads and nests runs from v1 when SDB querying is disabled", async () => { + const root: Run = { + id: "00000000-0000-0000-0000-000000000005", + name: "root", + run_type: "chain", + inputs: {}, + }; + const child: Run = { + id: "00000000-0000-0000-0000-000000000006", + name: "child", + run_type: "tool", + inputs: {}, + parent_run_id: root.id, + dotted_order: "2", + }; + const listRuns = jest.fn(() => fromArray([child, root])); + const queryV2 = jest.fn(); + const client = { + _supportsSDBQuery: async () => false, + runs: { queryV2 }, + _listRuns: listRuns, + } as unknown as Client; + + const runs = await loadTracesForExperiment(client, project, { + loadNested: true, + }); + + expect(runs).toEqual([root]); + expect(root.child_runs).toEqual([child]); + expect(listRuns).toHaveBeenCalledWith({ + projectId: project.id, + executionOrder: undefined, + }); + expect(queryV2).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tests/experiment_manager.int.test.ts b/src/lib/tests/experiment_manager.int.test.ts new file mode 100644 index 0000000..8832433 --- /dev/null +++ b/src/lib/tests/experiment_manager.int.test.ts @@ -0,0 +1,45 @@ +import { _ExperimentManager } from "../evaluation/_runner.js"; +import { Client } from "../index.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; + +const TESTING_DATASET_NAME = `test_experiment_manager_${uuidv4()}`; + +beforeAll(async () => { + const client = new Client(); + + if (!(await client.hasDataset({ datasetName: TESTING_DATASET_NAME }))) { + await client.createDataset(TESTING_DATASET_NAME, { + description: "For testing pruposes", + }); + + await client.createExamples({ + inputs: [{ input: 1 }, { input: 2 }], + outputs: [{ output: 2 }, { output: 3 }], + datasetName: TESTING_DATASET_NAME, + }); + } +}); + +afterAll(async () => { + const client = new Client(); + await client.deleteDataset({ datasetName: TESTING_DATASET_NAME }); +}); + +describe("experiment manager", () => { + test("can recover from collisions", async () => { + const client = new Client(); + const ds = await client.readDataset({ datasetName: TESTING_DATASET_NAME }); + const manager = new _ExperimentManager({ + data: TESTING_DATASET_NAME, + client, + numRepetitions: 1, + }); + const experimentName = manager._experimentName; + await client.createProject({ + projectName: experimentName, + referenceDatasetId: ds.id, + }); + await manager.start(); + expect(manager._experimentName).not.toEqual(experimentName); + }); +}); diff --git a/src/lib/tests/failed_traces.test.ts b/src/lib/tests/failed_traces.test.ts new file mode 100644 index 0000000..696e20f --- /dev/null +++ b/src/lib/tests/failed_traces.test.ts @@ -0,0 +1,233 @@ +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from "@jest/globals"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as os from "node:os"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { Client } from "../client.js"; + +async function makeTmpDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "ls-failed-traces-test-")); +} + +async function listDir(dir: string): Promise { + return fs.readdir(dir); +} + +async function readEnvelope(dir: string, filename: string) { + const raw = await fs.readFile(path.join(dir, filename), "utf8"); + return JSON.parse(raw) as { + version: number; + endpoint: string; + headers: Record; + body_base64: string; + }; +} + +function makeRunCreate() { + const id = uuidv4(); + return { + id, + name: "test_run", + run_type: "chain" as const, + inputs: { x: 1 }, + trace_id: id, + dotted_order: id, + }; +} + +describe("Client._writeTraceToFallbackDir", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await makeTmpDir(); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it("writes a self-contained JSON envelope", async () => { + const body = Buffer.from("hello world"); + const headers = { "Content-Type": "multipart/form-data; boundary=abc" }; + await (Client as any)._writeTraceToFallbackDir( + tmpDir, + body, + headers, + "runs/multipart", + ); + + const files = await listDir(tmpDir); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.json$/); + + const envelope = await readEnvelope(tmpDir, files[0]); + expect(envelope.version).toBe(1); + expect(envelope.endpoint).toBe("runs/multipart"); + expect(envelope.headers).toEqual(headers); + expect(Buffer.from(envelope.body_base64, "base64").toString()).toBe( + "hello world", + ); + }); + + it("swallows write errors instead of throwing", async () => { + const blocker = path.join(tmpDir, "blocker"); + await fs.writeFile(blocker, "not a dir"); + await expect( + (Client as any)._writeTraceToFallbackDir( + path.join(blocker, "sub"), + Buffer.alloc(0), + {}, + "runs/multipart", + ), + ).resolves.not.toThrow(); + }); + + it("drops new traces when directory exceeds maxBytes", async () => { + // Each envelope is ~149 bytes. Budget of 250 allows the first 2 files + // (~298 bytes total exceeds 250), so the 3rd write is dropped. + const body = Buffer.from("x".repeat(20)); + const headers = { "Content-Type": "multipart/form-data; boundary=abc" }; + for (let i = 0; i < 3; i++) { + await (Client as any)._writeTraceToFallbackDir( + tmpDir, + body, + headers, + "runs/multipart", + 250, + ); + } + const files = await listDir(tmpDir); + expect(files).toHaveLength(2); + }); +}); + +describe("Client multipart failure → fallback dir", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await makeTmpDir(); + jest.clearAllMocks(); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + jest.restoreAllMocks(); + }); + + it("writes a multipart envelope when the multipart upload fails", async () => { + const mockFetch = jest.fn((..._args: any[]) => + Promise.resolve({ + ok: false, + status: 500, + text: () => Promise.resolve("Server error"), + } as Response), + ) as jest.MockedFunction; + + const client = new Client({ + apiKey: "test", + autoBatchTracing: false, + fetchImplementation: mockFetch, + callerOptions: { maxRetries: 0 }, + }); + (client as any).failedTracesDir = tmpDir; + + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { use_multipart_endpoint: true }, + instance_flags: { gzip_body_enabled: false }, + }); + + const run = makeRunCreate(); + await (client as any)._processBatch([ + { + action: "create", + item: run, + apiKey: undefined, + apiUrl: undefined, + size: 100, + }, + ]); + + const files = await listDir(tmpDir); + expect(files).toHaveLength(1); + + const envelope = await readEnvelope(tmpDir, files[0]); + expect(envelope.endpoint).toBe("runs/multipart"); + expect(envelope.headers["Content-Type"]).toMatch( + /^multipart\/form-data; boundary=/, + ); + const decoded = Buffer.from(envelope.body_base64, "base64").toString(); + expect(decoded).toContain(run.id); + }); + + it("does not write a file when the multipart upload succeeds", async () => { + const mockFetch = jest.fn((..._args: any[]) => + Promise.resolve({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + } as Response), + ) as jest.MockedFunction; + + const client = new Client({ + apiKey: "test", + autoBatchTracing: false, + fetchImplementation: mockFetch, + }); + (client as any).failedTracesDir = tmpDir; + + jest.spyOn(client as any, "_ensureServerInfo").mockResolvedValue({ + version: "foo", + batch_ingest_config: { use_multipart_endpoint: true }, + instance_flags: { gzip_body_enabled: false }, + }); + + await (client as any)._processBatch([ + { + action: "create", + item: makeRunCreate(), + apiKey: undefined, + apiUrl: undefined, + size: 100, + }, + ]); + + expect(await listDir(tmpDir)).toHaveLength(0); + }); +}); + +describe("LANGSMITH_FAILED_TRACES_DIR environment variable", () => { + it("sets failedTracesDir from the env var", () => { + const original = process.env.LANGSMITH_FAILED_TRACES_DIR; + process.env.LANGSMITH_FAILED_TRACES_DIR = "/some/path"; + try { + const client = new Client({ apiKey: "test" }); + expect((client as any).failedTracesDir).toBe("/some/path"); + } finally { + if (original === undefined) + delete process.env.LANGSMITH_FAILED_TRACES_DIR; + else process.env.LANGSMITH_FAILED_TRACES_DIR = original; + } + }); + + it("leaves failedTracesDir undefined when the env var is absent", () => { + const original = process.env.LANGSMITH_FAILED_TRACES_DIR; + delete process.env.LANGSMITH_FAILED_TRACES_DIR; + try { + const client = new Client({ apiKey: "test" }); + expect((client as any).failedTracesDir).toBeUndefined(); + } finally { + if (original !== undefined) + process.env.LANGSMITH_FAILED_TRACES_DIR = original; + } + }); +}); diff --git a/src/lib/tests/fetch.test.ts b/src/lib/tests/fetch.test.ts new file mode 100644 index 0000000..ea8827c --- /dev/null +++ b/src/lib/tests/fetch.test.ts @@ -0,0 +1,125 @@ +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest } from "@jest/globals"; +import { Client } from "../client.js"; +import { overrideFetchImplementation } from "../singletons/fetch.js"; +import { traceable } from "../traceable.js"; + +describe.each([[""], ["mocked"]])("Client uses %s fetch", (description) => { + let globalFetchMock: jest.Mock; + let overriddenFetch: jest.Mock; + let expectedFetchMock: jest.Mock; + let unexpectedFetchMock: jest.Mock; + + beforeEach(() => { + globalFetchMock = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + batch_ingest_config: { + use_multipart_endpoint: true, + }, + }), + text: () => Promise.resolve(""), + }), + ); + overriddenFetch = jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + batch_ingest_config: { + use_multipart_endpoint: true, + }, + }), + text: () => Promise.resolve(""), + }), + ); + expectedFetchMock = + description === "mocked" ? overriddenFetch : globalFetchMock; + unexpectedFetchMock = + description === "mocked" ? globalFetchMock : overriddenFetch; + + if (description === "mocked") { + overrideFetchImplementation(overriddenFetch); + } else { + overrideFetchImplementation(globalFetchMock); + } + // Mock global fetch + (globalThis as any).fetch = globalFetchMock; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("createLLMExample", () => { + it("should create an example with the given input and generation", async () => { + const client = new Client({ apiKey: "test-api-key" }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + instance_flags: { dataset_examples_multipart_enabled: true }, + }; + }); + const input = "Hello, world!"; + const generation = "Bonjour, monde!"; + const options = { datasetName: "test-dataset" }; + + await client.createLLMExample(input, generation, options); + expect(expectedFetchMock).toHaveBeenCalled(); + expect(unexpectedFetchMock).not.toHaveBeenCalled(); + }); + }); + + describe("createChatExample", () => { + it("should convert LangChainBaseMessage objects to examples", async () => { + const client = new Client({ apiKey: "test-api-key" }); + jest.spyOn(client as any, "_getServerInfo").mockImplementation(() => { + return { + version: "foo", + instance_flags: { dataset_examples_multipart_enabled: true }, + }; + }); + const input = [ + { text: "Hello", sender: "user" }, + { text: "Hi there", sender: "bot" }, + ]; + const generations = { + type: "langchain", + data: { text: "Bonjour", sender: "bot" }, + }; + const options = { datasetName: "test-dataset" }; + + await client.createChatExample(input, generations, options); + + expect(expectedFetchMock).toHaveBeenCalled(); + expect(unexpectedFetchMock).not.toHaveBeenCalled(); + }); + }); + + test("basic traceable implementation", async () => { + const client = new Client({ apiKey: "test-api-key" }); + process.env.LANGSMITH_TRACING_BACKGROUND = "false"; + const llm = traceable( + async function* llm(input: string) { + const response = input.repeat(2).split(""); + for (const char of response) { + yield char; + } + }, + { tracingEnabled: true, client }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of llm("Hello world")) { + // pass + } + + await client.awaitPendingTraceBatches(); + + expect(expectedFetchMock).toHaveBeenCalled(); + expect(unexpectedFetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tests/git_info.test.ts b/src/lib/tests/git_info.test.ts new file mode 100644 index 0000000..7396f41 --- /dev/null +++ b/src/lib/tests/git_info.test.ts @@ -0,0 +1,134 @@ +import { jest, describe, test, expect, beforeEach } from "@jest/globals"; +import type { Example } from "../schemas.js"; + +let remoteUrl = "https://github.com/langchain-ai/langsmith-sdk.git"; + +const execMock = jest.fn( + ( + command: string, + callback: (error: Error | null, stdout: string) => void, + ) => { + const outputs: Record = { + "git rev-parse --is-inside-work-tree": "true", + "git remote get-url origin": remoteUrl, + "git rev-parse HEAD": "abc123", + "git log -1 --format=%ct": "1720000000", + "git rev-parse --abbrev-ref HEAD": "main", + "git describe --tags --exact-match --always --dirty": "abc123", + "git status --porcelain": "", + "git log -1 --format=%an": "LangSmith", + "git log -1 --format=%ae": "langsmith@example.com", + }; + const output = outputs[command]; + if (output === undefined) { + callback(new Error(`Unexpected command: ${command}`), ""); + return; + } + callback(null, `${output}\n`); + }, +); + +jest.unstable_mockModule("child_process", () => ({ + exec: execMock, +})); + +const { getGitInfo } = await import("../utils/_git.js"); +const { _ExperimentManager } = await import("../evaluation/_runner.js"); + +function makeExample(): Example { + const now = new Date().toISOString(); + return { + id: "00000000-0000-0000-0000-000000000001", + inputs: { input: "hello" }, + outputs: {}, + dataset_id: "00000000-0000-0000-0000-000000000000", + created_at: now, + modified_at: now, + runs: [], + }; +} + +describe("git info", () => { + beforeEach(() => { + remoteUrl = "https://github.com/langchain-ai/langsmith-sdk.git"; + execMock.mockClear(); + }); + + test.each([ + [ + "credential-bearing HTTPS", + "https://user:token@github.com/langchain-ai/langsmith-sdk.git", + "https://github.com/langchain-ai/langsmith-sdk.git", + ], + [ + "percent-encoded userinfo", + "https://user%40example.com:p%40ss%2Fword@github.com/org/repo.git", + "https://github.com/org/repo.git", + ], + [ + "safe HTTPS", + "https://github.com/langchain-ai/langsmith-sdk.git", + "https://github.com/langchain-ai/langsmith-sdk.git", + ], + [ + "SSH URL", + "ssh://git@github.com/langchain-ai/langsmith-sdk.git", + "ssh://git@github.com/langchain-ai/langsmith-sdk.git", + ], + [ + "scp-style remote", + "git@github.com:langchain-ai/langsmith-sdk.git", + "git@github.com:langchain-ai/langsmith-sdk.git", + ], + ])("getGitInfo handles %s", async (_name, input, expected) => { + remoteUrl = input; + const gitInfo = await getGitInfo(); + expect(gitInfo?.remoteUrl).toBe(expected); + }); + + test("evaluation project payloads use sanitized git remotes", async () => { + const rawRemoteUrl = + "https://user:token@github.com/langchain-ai/langsmith-sdk.git"; + const sanitizedRemoteUrl = + "https://github.com/langchain-ai/langsmith-sdk.git"; + remoteUrl = rawRemoteUrl; + const createProjectCalls: any[] = []; + const updateProjectCalls: any[] = []; + const client = { + createProject: async (params: any) => { + createProjectCalls.push(params); + return { + id: "00000000-0000-0000-0000-000000000004", + name: "test-project", + reference_dataset_id: "00000000-0000-0000-0000-000000000000", + extra: { metadata: {} }, + }; + }, + updateProject: async (id: string, params: any) => { + updateProjectCalls.push([id, params]); + return {}; + }, + getDatasetUrl: async () => "http://test.com", + } as any; + + const manager = new _ExperimentManager({ + examples: [makeExample()], + client, + }); + const startedManager = await manager.start(); + await startedManager._end(); + + expect(createProjectCalls).toHaveLength(1); + expect(createProjectCalls[0].metadata.git.remoteUrl).toBe( + sanitizedRemoteUrl, + ); + expect(JSON.stringify(createProjectCalls[0])).not.toContain(rawRemoteUrl); + expect(updateProjectCalls).toHaveLength(1); + expect(updateProjectCalls[0][1].metadata.git.remoteUrl).toBe( + sanitizedRemoteUrl, + ); + expect(JSON.stringify(updateProjectCalls[0][1])).not.toContain( + rawRemoteUrl, + ); + }); +}); diff --git a/src/lib/tests/guard.vitesttest.ts b/src/lib/tests/guard.vitesttest.ts new file mode 100644 index 0000000..6ca5f88 --- /dev/null +++ b/src/lib/tests/guard.vitesttest.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { traceable, withRunTree } from "../traceable.js"; +import type { RunTree } from "../run_trees.js"; +import { isTracingEnabled } from "../utils/guard.js"; +import { mockClient } from "./utils/vitest_mock_client.js"; + +beforeEach(() => { + vi.stubEnv("LANGCHAIN_TRACING", undefined); + vi.stubEnv("LANGSMITH_TRACING", undefined); + vi.stubEnv("LANGSMITH_TRACING_V2", undefined); + vi.stubEnv("LANGCHAIN_TRACING_V2", undefined); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("isTracingEnabled", () => { + it("returns false without an active run tree or tracing env var", () => { + expect(isTracingEnabled()).toBe(false); + }); + + it.each([ + "LANGCHAIN_TRACING", + "LANGSMITH_TRACING", + "LANGCHAIN_TRACING_V2", + "LANGSMITH_TRACING_V2", + ])("reads %s=true without an active run tree", (envVar) => { + vi.stubEnv(envVar, "true"); + expect(isTracingEnabled()).toBe(true); + }); + + it("traceable without any env vars", async () => { + const { client, callSpy } = mockClient(); + const traced = traceable(() => isTracingEnabled(), { client }); + + await expect(traced()).resolves.toBe(false); + expect(callSpy).toHaveBeenCalledTimes(0); + }); + + it("traceable with tracingEnabled=true override", async () => { + vi.stubEnv("LANGSMITH_TRACING", "false"); + + const { client, callSpy } = mockClient(); + const traced = traceable(() => isTracingEnabled(), { + client, + tracingEnabled: true, + }); + + await expect(traced()).resolves.toBe(true); + expect(callSpy).toHaveBeenCalledTimes(1); + }); + + it("traceable with env + tracingEnabled=false", async () => { + vi.stubEnv("LANGSMITH_TRACING", "true"); + + const { client, callSpy } = mockClient(); + const traced = traceable(() => isTracingEnabled(), { + client, + tracingEnabled: false, + }); + + await expect(traced()).resolves.toBe(false); + expect(callSpy).toHaveBeenCalledTimes(0); + }); + + it.each([ + ["LANGSMITH_TRACING", "true"], + ["LANGSMITH_TRACING", "false"], + ])("withRunTree from env var $1", async (_, value) => { + vi.stubEnv("LANGSMITH_TRACING", value); + + await expect( + withRunTree({} as RunTree, () => isTracingEnabled()), + ).resolves.toBe(value === "true"); + }); + + it("withRunTree explicit tracingEnabled", async () => { + await expect( + withRunTree({ tracingEnabled: false } as RunTree, () => + isTracingEnabled(), + ), + ).resolves.toBe(false); + + await expect( + withRunTree({ tracingEnabled: true } as RunTree, () => + isTracingEnabled(), + ), + ).resolves.toBe(true); + }); +}); diff --git a/src/lib/tests/jestlike/experiment_metadata.test.ts b/src/lib/tests/jestlike/experiment_metadata.test.ts new file mode 100644 index 0000000..39b701d --- /dev/null +++ b/src/lib/tests/jestlike/experiment_metadata.test.ts @@ -0,0 +1,136 @@ +import { jest } from "@jest/globals"; + +import { Client } from "../../client.js"; +import { evaluatorLogFeedbackPromises } from "../../utils/jestlike/globals.js"; +import { generateWrapperFromJestlikeMethods } from "../../utils/jestlike/index.js"; + +const FIRST_MODIFIED_AT = "2099-01-01T00:00:00.000Z"; +const SECOND_MODIFIED_AT = "2099-02-01T00:00:00.000Z"; + +test("scopes the final example version to each experiment", async () => { + evaluatorLogFeedbackPromises.clear(); + + const beforeAllHooks: Array<() => Promise> = []; + const afterAllHooks: Array<() => Promise> = []; + const testFunctions: Array<() => Promise> = []; + + const describe = Object.assign((_name: string, fn: () => void) => fn(), { + only: (_name: string, fn: () => void) => fn(), + skip: jest.fn(), + concurrent: (_name: string, fn: () => void) => fn(), + }); + const testMethod = Object.assign( + (_name: string, fn: () => Promise) => { + testFunctions.push(fn); + }, + { + only: jest.fn(), + skip: jest.fn(), + concurrent: jest.fn(), + each: jest.fn(), + }, + ); + + const resolved = (value: T) => + jest.fn<() => Promise>().mockResolvedValue(value); + const updateProject = jest + .fn<(_projectId: string, _options: unknown) => Promise>() + .mockResolvedValue(undefined); + const client = { + readDataset: resolved({ + id: "11111111-1111-4111-8111-111111111111", + name: "shared-dataset", + }), + createProject: jest + .fn<() => Promise<{ id: string; name: string }>>() + .mockResolvedValueOnce({ + id: "22222222-2222-4222-8222-222222222222", + name: "first-project", + }) + .mockResolvedValueOnce({ + id: "44444444-4444-4444-8444-444444444444", + name: "second-project", + }), + getDatasetUrl: resolved("https://example.com/datasets/1"), + readExample: jest + .fn<() => Promise>>() + .mockResolvedValueOnce({ + id: "33333333-3333-4333-8333-333333333333", + dataset_id: "11111111-1111-4111-8111-111111111111", + inputs: { question: "first" }, + outputs: { answer: "first" }, + metadata: {}, + created_at: FIRST_MODIFIED_AT, + modified_at: FIRST_MODIFIED_AT, + }) + .mockResolvedValueOnce({ + id: "55555555-5555-4555-8555-555555555555", + dataset_id: "11111111-1111-4111-8111-111111111111", + inputs: { question: "second" }, + outputs: { answer: "second" }, + metadata: {}, + created_at: SECOND_MODIFIED_AT, + modified_at: SECOND_MODIFIED_AT, + }), + createRun: resolved(undefined), + updateRun: resolved(undefined), + logEvaluationFeedback: resolved(undefined), + awaitPendingTraceBatches: resolved(undefined), + updateProject, + updateDatasetTag: resolved(undefined), + } as unknown as Client; + + const ls = generateWrapperFromJestlikeMethods( + { + expect, + test: testMethod, + describe, + beforeAll: (fn: () => Promise) => beforeAllHooks.push(fn), + afterAll: (fn: () => Promise) => afterAllHooks.push(fn), + }, + "jest", + ); + + const registerSuite = (question: string) => { + ls.describe( + "shared-dataset", + () => { + ls.test( + `${question}-test`, + { + inputs: { question }, + referenceOutputs: { answer: question }, + }, + () => ({ answer: question }), + ); + }, + { client, enableTestTracking: true }, + ); + }; + + registerSuite("first"); + registerSuite("second"); + + for (const hook of beforeAllHooks) await hook(); + for (const fn of testFunctions) await fn(); + for (const hook of afterAllHooks) await hook(); + + expect(updateProject).toHaveBeenCalledWith( + "22222222-2222-4222-8222-222222222222", + expect.objectContaining({ + metadata: expect.objectContaining({ + dataset_version: FIRST_MODIFIED_AT, + }), + }), + ); + expect(updateProject).toHaveBeenCalledWith( + "44444444-4444-4444-8444-444444444444", + expect.objectContaining({ + metadata: expect.objectContaining({ + dataset_version: SECOND_MODIFIED_AT, + }), + }), + ); + + evaluatorLogFeedbackPromises.clear(); +}); diff --git a/src/lib/tests/jestlike/globals.test.ts b/src/lib/tests/jestlike/globals.test.ts new file mode 100644 index 0000000..83c764e --- /dev/null +++ b/src/lib/tests/jestlike/globals.test.ts @@ -0,0 +1,50 @@ +import { + _logTestFeedback, + evaluatorLogFeedbackPromises, +} from "../../utils/jestlike/globals.js"; + +describe("Jest-like feedback logging", () => { + afterEach(() => { + evaluatorLogFeedbackPromises.clear(); + }); + + test("supplies the test experiment ID and root run start time", async () => { + const calls: any[][] = []; + const startTime = 1784651696789; + const client = { + logEvaluationFeedback: async (...args: any[]) => { + calls.push(args); + return []; + }, + }; + + _logTestFeedback({ + exampleId: "00000000-0000-0000-0000-000000000001", + feedback: { key: "pass", score: true }, + context: { + enableTestTracking: true, + createdAt: new Date().toISOString(), + client: client as any, + project: { + id: "00000000-0000-0000-0000-000000000004", + } as any, + suiteUuid: "00000000-0000-0000-0000-000000000002", + suiteName: "test-suite", + }, + runTree: { + id: "00000000-0000-0000-0000-000000000003", + start_time: startTime, + } as any, + client: client as any, + }); + await Promise.all(evaluatorLogFeedbackPromises); + + expect(calls).toHaveLength(1); + expect(calls[0]).toEqual([ + expect.objectContaining({ + run: expect.objectContaining({ start_time: startTime }), + projectId: "00000000-0000-0000-0000-000000000004", + }), + ]); + }); +}); diff --git a/src/lib/tests/jestlike/jest.int.test.ts b/src/lib/tests/jestlike/jest.int.test.ts new file mode 100644 index 0000000..20124d5 --- /dev/null +++ b/src/lib/tests/jestlike/jest.int.test.ts @@ -0,0 +1,27 @@ +import { test } from "@jest/globals"; +import { OpenAIEmbeddings } from "@langchain/openai"; + +import * as ls from "../../jest/index.js"; + +const embeddings = new OpenAIEmbeddings({ + model: "text-embedding-3-small", +}); + +test("Should test relative closeness custom matcher", async () => { + await ls.expect("carrot").toBeSemanticCloseTo("carrot cake", { + threshold: 0.5, + embeddings, + }); + await ls.expect("carrot").not.toBeSemanticCloseTo("airplane", { + threshold: 0.5, + embeddings, + }); + await ls.expect("carrot").not.toBeSemanticCloseTo("airplane", { + threshold: 0, + embeddings, + }); + await ls.expect("carrot").toBeSemanticCloseTo("airplane", { + threshold: 1, + embeddings, + }); +}); diff --git a/src/lib/tests/jestlike/jest.test.ts b/src/lib/tests/jestlike/jest.test.ts new file mode 100644 index 0000000..8caa79c --- /dev/null +++ b/src/lib/tests/jestlike/jest.test.ts @@ -0,0 +1,293 @@ +import { test } from "@jest/globals"; +import { AsyncLocalStorage } from "node:async_hooks"; + +import * as ls from "../../jest/index.js"; +import { type SimpleEvaluator } from "../../jest/index.js"; +import { _objectHash } from "../../utils/jestlike/index.js"; +import { traceable } from "../../traceable.js"; + +const myEvaluator: SimpleEvaluator = (params) => { + const { referenceOutputs, outputs } = params; + if (outputs.bar === referenceOutputs.bar) { + return { + key: "quality", + score: 1, + }; + } else if (outputs.bar === "goodval") { + return { + key: "quality", + score: 0.5, + }; + } else { + return { + key: "quality", + score: 0, + }; + } +}; + +const unrelatedStore = new AsyncLocalStorage(); +unrelatedStore.enterWith("value"); // Ensure that this works despite https://github.com/jestjs/jest/issues/13653 + +ls.describe( + "js unit testing test demo", + () => { + ls.test( + "Should succeed with a defined evaluator", + { inputs: { foo: "bar" }, referenceOutputs: { bar: "qux" } }, + async ({ inputs: _inputs, referenceOutputs }) => { + const myApp = () => { + return referenceOutputs; + }; + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .toBeGreaterThanOrEqual(0.5); + ls.logFeedback({ + key: "readability", + score: 0.9, + }); + ls.logFeedback({ + key: "readability 2", + score: 0.9, + }); + ls.logFeedback({ + key: "readability 3", + score: 0.9, + }); + ls.logFeedback({ + key: "readability 4", + score: 0.9, + }); + ls.logFeedback({ + key: "readability 5", + score: 0.9, + }); + ls.logOutputs({ + bar: "perfect", + }); + }, + ); + + ls.test( + "Should work with repetitions", + { + inputs: { foo: "bar" }, + referenceOutputs: { foo: "bar" }, + config: { repetitions: 3 }, + }, + async ({ inputs: _inputs, referenceOutputs: _referenceOutputs }) => { + const myApp = () => { + return { bar: "goodval" }; + }; + const res = myApp(); + ls.logFeedback({ + key: "readability", + score: 0.8, + }); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .toBeGreaterThanOrEqual(0.5); + return res; + }, + ); + + ls.test( + "Logging feedback should work in nested traceable", + { + inputs: { nested: "nested" }, + referenceOutputs: { nested: "nested" }, + }, + async ({ inputs: _inputs, referenceOutputs: _referenceOutputs }) => { + const myApp = () => { + return { bar: "goodval" }; + }; + const res = myApp(); + const nested = traceable(() => { + ls.logFeedback({ + key: "nested", + score: 0.8, + }); + }); + await nested(); + return res; + }, + ); + + ls.test( + "Should fail with some defined evaluator", + { inputs: { foo: "bad" }, referenceOutputs: { baz: "qux" } }, + async ({ inputs: _inputs, referenceOutputs: _referenceOutputs }) => { + const myApp = () => { + return { bar: "bad" }; + }; + const res = myApp(); + ls.logFeedback({ + key: "readability", + score: 0.1, + }); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .not.toBeGreaterThanOrEqual(0.5); + return res; + }, + ); + + ls.test.concurrent.each( + [ + { + inputs: { + one: "uno", + }, + referenceOutputs: { + ein: "un", + }, + }, + { + inputs: { + two: "dos", + }, + referenceOutputs: { + zwei: "deux", + }, + split: "test-split", + metadata: { + foo: "bar", + }, + }, + ], + { repetitions: 3, metadata: { something: "cool" } }, + )( + "Counts to ten", + async ({ inputs: _inputs, referenceOutputs: _referenceOutputs }) => { + const myApp = () => { + return { bar: "bad" }; + }; + ls.logFeedback({ + key: "readability", + score: 0.6, + }); + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .not.toBeGreaterThanOrEqual(0.5); + ls.logOutputs(res); + }, + ); + + test("Absolute closeness custom matcher", async () => { + await ls.expect("foobar").toBeAbsoluteCloseTo("foobaz", { + threshold: 3, + }); + await ls.expect("foobar").not.toBeAbsoluteCloseTo("foobaz", { + threshold: 0, + }); + await ls.expect("foobar").not.toBeAbsoluteCloseTo("barfoo", { + threshold: 3, + }); + }); + + test("Relative closeness custom matcher", async () => { + await ls.expect("0123456789").toBeRelativeCloseTo("1123456789", { + threshold: 0.1, + }); + await ls.expect("0123456789").not.toBeRelativeCloseTo("111111111", { + threshold: 0.1, + }); + await ls.expect("0123456789").not.toBeRelativeCloseTo("1", { + threshold: 0, + }); + await ls.expect("0123456789").toBeRelativeCloseTo("1", { + threshold: 1, + }); + }); + }, + { + metadata: { + model: "test-model", + }, + }, +); + +const scoreMarketingCopyAgent = async () => { + return { + key: "marketing_copy_score", + score: 0.5, + }; +}; + +ls.describe("Test Tweet", () => { + ls.test( + "should generate a tweet LS", + { + inputs: { + request: "Write a tweet about LLMs", + }, + referenceOutputs: {}, + }, + async ({ inputs: { request } }: { inputs: { request: string } }) => { + const result = request.repeat(2); + ls.logOutputs({ response: result }); + ls.logFeedback({ + key: "length", + score: result.length, + }); + ls.logFeedback({ + key: "twitter_length", + score: result.length <= 280, + }); + const wrappedEvaluator = ls.wrapEvaluator(scoreMarketingCopyAgent); + await wrappedEvaluator({ + content: result, + query_type: "tweet", + }); + }, + ); +}); + +ls.describe("Test Linkedin Post", () => { + ls.test( + "should generate a linkedin post LS", + { + inputs: { + request: "Write a linkedin post about LLMs", + }, + referenceOutputs: {}, + }, + async ({ inputs: { request } }: { inputs: { request: string } }) => { + const result = request.repeat(2); + ls.logOutputs({ response: result }); + ls.logFeedback({ + key: "length", + score: result.length, + }); + ls.logFeedback({ + key: "linkedin_length", + score: result.length > 280, + }); + ls.logFeedback({ + key: "multiline", + score: result.split("\n").length > 2, + }); + const wrappedEvaluator = ls.wrapEvaluator(scoreMarketingCopyAgent); + await wrappedEvaluator({ + content: result, + query_type: "linkedin post", + }); + }, + ); +}); + +test("object hash should work on undefined values", async () => { + expect( + _objectHash({ + foo: "bar", + baz: undefined, + qux: null, + }), + ).toEqual("88d67a35803b03a787d9fce25ebed027807c68ce0c3dee9f818fc58a43dd10af"); +}); diff --git a/src/lib/tests/jestlike/jest_existing_dataset.int.test.ts b/src/lib/tests/jestlike/jest_existing_dataset.int.test.ts new file mode 100644 index 0000000..e34f1ca --- /dev/null +++ b/src/lib/tests/jestlike/jest_existing_dataset.int.test.ts @@ -0,0 +1,61 @@ +import { describe } from "@jest/globals"; +import { v4 as uuidv4 } from "../../utils/uuid/src/index.js"; + +import { Client } from "../../client.js"; +import * as ls from "../../jest/index.js"; + +describe("Existing dataset", () => { + const client = new Client({ callerOptions: { maxRetries: 6 } }); + const datasetName = `__test_existing_dataset${uuidv4().slice(0, 4)}`; + const exampleIds = [uuidv4(), uuidv4()]; + const exampleParams = [ + { + id: exampleIds[0], + inputs: { foo: "bar" }, + outputs: { foo: "bar" }, + }, + { + id: exampleIds[1], + inputs: { foo: "bar" }, + outputs: { foo: "bar" }, + }, + ]; + + beforeAll(async () => { + // Clean up existing dataset if it exists + if (await client.hasDataset({ datasetName })) { + await client.deleteDataset({ datasetName }); + } + + // Create actual dataset + const dataset = await client.createDataset(datasetName, { + description: "Test dataset for multipart example upload", + dataType: "kv", + }); + + await client.createExamples([ + { + ...exampleParams[0], + dataset_id: dataset.id, + }, + { + ...exampleParams[1], + dataset_id: dataset.id, + }, + ]); + }); + + afterAll(async () => { + await client.deleteDataset({ datasetName }); + }); + + ls.describe(datasetName, () => { + ls.test.each(exampleParams)( + "Runs over existing dataset", + async (example) => { + expect(example.inputs.foo).toBe("bar"); + expect(example.outputs.foo).toBe("bar"); + }, + ); + }); +}); diff --git a/src/lib/tests/jestlike/vitest.vitesteval.ts b/src/lib/tests/jestlike/vitest.vitesteval.ts new file mode 100644 index 0000000..5624d33 --- /dev/null +++ b/src/lib/tests/jestlike/vitest.vitesteval.ts @@ -0,0 +1,131 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import * as ls from "../../vitest/index.mjs"; +import { type SimpleEvaluator } from "../../vitest/index.mjs"; + +const myEvaluator: SimpleEvaluator = (params) => { + const { referenceOutputs, outputs } = params; + if (outputs.bar === referenceOutputs.bar) { + return { + key: "quality", + score: 1, + }; + } else if (outputs.bar === "goodval") { + return { + key: "quality", + score: 0.5, + }; + } else { + return { + key: "quality", + score: 0, + }; + } +}; + +const unrelatedStore = new AsyncLocalStorage(); +unrelatedStore.enterWith("value"); // Ensure that this works despite https://github.com/jestjs/jest/issues/13653 + +ls.describe( + "js vitest", + () => { + ls.test( + "Should succeed with some defined evaluator", + { inputs: { foo: "bar" }, referenceOutputs: { bar: "qux" } }, + async ({ inputs: _inputs, referenceOutputs }) => { + const myApp = () => { + return referenceOutputs; + }; + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .toBeGreaterThanOrEqual(0.5); + ls.logFeedback({ + key: "coolness", + score: 0.5, + }); + ls.logOutputs({ + testLoggedOutput: "logged", + }); + }, + ); + + ls.test( + "Should work with repetitions", + { + inputs: { foo: "bar" }, + referenceOutputs: { foo: "bar" }, + config: { repetitions: 3 }, + }, + async ({ inputs: _inputs, referenceOutputs: _referenceOutputs }) => { + const myApp = () => { + return { bar: "goodval" }; + }; + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .toBeGreaterThanOrEqual(0.5); + return res; + }, + ); + + ls.test( + "Should fail with some defined evaluator", + { inputs: { foo: "bad" }, referenceOutputs: { baz: "qux" } }, + async ({ inputs: _inputs, referenceOutputs: _expected }) => { + const myApp = () => { + return { bar: "bad" }; + }; + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .not.toBeGreaterThanOrEqual(0.5); + return res; + }, + ); + + ls.test.concurrent.each( + [ + { + inputs: { + one: "uno", + }, + referenceOutputs: { + ein: "un", + }, + }, + { + inputs: { + two: "dos", + }, + referenceOutputs: { + zwei: "deux", + }, + }, + ], + { repetitions: 3, metadata: { something: "cool" } }, + )( + "Does the thing", + async ({ inputs: _inputs, referenceOutputs: _outputs }) => { + const myApp = () => { + return { bar: "bad" }; + }; + const res = myApp(); + await ls + .expect(res) + .evaluatedBy(myEvaluator) + .not.toBeGreaterThanOrEqual(0.5); + return res; + }, + ); + }, + { + metadata: { + model: "test-model", + }, + testSuiteName: "js-vitest-testing", + }, +); diff --git a/src/lib/tests/jestlike/vitest_separate_file.vitesteval.ts b/src/lib/tests/jestlike/vitest_separate_file.vitesteval.ts new file mode 100644 index 0000000..10aff87 --- /dev/null +++ b/src/lib/tests/jestlike/vitest_separate_file.vitesteval.ts @@ -0,0 +1,90 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import * as ls from "../../vitest/index.mjs"; + +const myEvaluator = () => { + return { key: "accuracy", score: Math.random() }; +}; + +const unrelatedStore = new AsyncLocalStorage(); +unrelatedStore.enterWith("value"); // Ensure that this works despite https://github.com/jestjs/jest/issues/13653 + +ls.describe( + "js vitest 2", + () => { + ls.test.each( + [ + { + inputs: { + one: "uno", + }, + referenceOutputs: { + ein: "un", + }, + }, + { + inputs: { + two: "dos", + }, + referenceOutputs: { + zwei: "deux", + }, + }, + ], + { repetitions: 3, metadata: { something: "cool" } }, + )("Does the thing", async ({ inputs, referenceOutputs }) => { + const myApp = () => { + return { bar: "bad" }; + }; + const res = myApp(); + const evaluator = ls.wrapEvaluator(myEvaluator); + await evaluator({ inputs, referenceOutputs, outputs: res }); + return res; + }); + }, + { + metadata: { + model: "test-model", + }, + }, +); + +ls.describe( + "js vitest 3", + () => { + ls.test.each( + [ + { + inputs: { + one: "uno", + }, + referenceOutputs: { + ein: "un", + }, + }, + { + inputs: { + two: "dos", + }, + referenceOutputs: { + zwei: "deux", + }, + }, + ], + { repetitions: 3, metadata: { something: "cool" } }, + )("Does the thing", async ({ inputs, referenceOutputs }) => { + const myApp = () => { + return { bar: "bad" }; + }; + const res = myApp(); + const evaluator = ls.wrapEvaluator(myEvaluator); + await evaluator({ inputs, referenceOutputs, outputs: res }); + return res; + }); + }, + { + metadata: { + model: "test-model", + }, + }, +); diff --git a/src/lib/tests/jestlike/wrap_jest.test.ts b/src/lib/tests/jestlike/wrap_jest.test.ts new file mode 100644 index 0000000..589dd77 --- /dev/null +++ b/src/lib/tests/jestlike/wrap_jest.test.ts @@ -0,0 +1,28 @@ +import * as jest from "@jest/globals"; +import { SimpleEvaluator, wrapJest } from "../../jest/index.js"; + +const ls = wrapJest(jest); + +const myEvaluator: SimpleEvaluator = () => { + return { + key: "quality", + score: 1, + }; +}; + +ls.describe("wrapped jest", () => { + ls.test( + "should work", + { + inputs: { foo: "bar" }, + referenceOutputs: { foo: "bar" }, + }, + async ({ referenceOutputs }) => { + const myApp = () => { + return referenceOutputs; + }; + const res = myApp(); + await ls.expect(res).evaluatedBy(myEvaluator).toBeGreaterThanOrEqual(0.5); + }, + ); +}); diff --git a/src/lib/tests/jestlike/wrap_vitest.vitesteval.ts b/src/lib/tests/jestlike/wrap_vitest.vitesteval.ts new file mode 100644 index 0000000..da54fa5 --- /dev/null +++ b/src/lib/tests/jestlike/wrap_vitest.vitesteval.ts @@ -0,0 +1,45 @@ +import * as vitest from "vitest"; +import { SimpleEvaluator, wrapVitest } from "../../vitest/index.mjs"; + +const ls = wrapVitest(vitest); + +const myEvaluator: SimpleEvaluator = () => { + return { + key: "quality", + score: 1, + }; +}; + +ls.describe("wrapped vitest", () => { + ls.test( + "should work", + { + inputs: { foo: "bar" }, + referenceOutputs: { foo: "bar" }, + }, + async ({ referenceOutputs }) => { + const myApp = () => { + return referenceOutputs; + }; + const res = myApp(); + await ls.expect(res).evaluatedBy(myEvaluator).toBeGreaterThanOrEqual(0.5); + }, + ); + + ls.test.skip( + "should report output on thrown errors/assertion failures", + { + inputs: { foo: "qux" }, + referenceOutputs: { foo: "qux" }, + }, + async ({ referenceOutputs }) => { + const myApp = () => { + return referenceOutputs; + }; + const res = myApp(); + ls.logOutputs({ res }); + // Will fail, can't run in CI because Vitest doesn't have xfail + await ls.expect(res).evaluatedBy(myEvaluator).toBeLessThanOrEqual(0.5); + }, + ); +}); diff --git a/src/lib/tests/lcls_handoff.int.test.ts b/src/lib/tests/lcls_handoff.int.test.ts new file mode 100644 index 0000000..c938bb0 --- /dev/null +++ b/src/lib/tests/lcls_handoff.int.test.ts @@ -0,0 +1,98 @@ +import { BaseMessage, HumanMessage } from "@langchain/core/messages"; +import { RunnableConfig, RunnableLambda } from "@langchain/core/runnables"; +import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain"; +import { MessageGraph } from "@langchain/langgraph"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { Client } from "../client.js"; +import { Run } from "../schemas.js"; +import { traceable } from "../traceable.js"; +import { toArray, waitUntil } from "./utils.js"; +import { requiresClickhouse } from "./utils/markers.js"; + +requiresClickhouse.test( + "Test handoff between run tree and LangChain code.", + async () => { + const projectName = `__test_handoff ${uuidv4()}`; + + // Define a new graph + const workflow = new MessageGraph(); + + const addValueTraceable = traceable( + (msg: BaseMessage) => { + return new HumanMessage({ content: msg.content + " world" }); + }, + { + name: "add_negligible_value", + }, + ); + + const myFunc = async (messages: BaseMessage[], config?: RunnableConfig) => { + const runnableConfig = config ?? { callbacks: [] }; + const newMsg = await addValueTraceable( + runnableConfig, + messages[0] as HumanMessage, + ); + return [newMsg]; + }; + + // Define the two nodes we will cycle between + workflow + .addNode( + "agent", + new RunnableLambda({ + func: async () => new HumanMessage({ content: "Hello!" }), + }), + ) + .addNode("action", new RunnableLambda({ func: myFunc })) + .addEdge("__start__", "agent") + .addEdge("agent", "action") + .addEdge("action", "__end__"); + + const app = workflow.compile(); + const tracer = new LangChainTracer({ projectName }); + const client = new Client({ + callerOptions: { maxRetries: 3 }, + timeout_ms: 30_000, + }); + try { + const runId = uuidv4(); + const result = await app.invoke( + [new HumanMessage({ content: "Hello!" })], + { + callbacks: [tracer], + runId, + }, + ); + expect(result[result.length - 1].content).toEqual("Hello! world"); + + // First wait until at least one trace is found in the project + const getNestedFunction = (): Promise => + toArray( + client.listRuns({ + projectName, + filter: "eq(name, 'add_negligible_value')", + }), + ); + await waitUntil( + async () => { + const traces = await getNestedFunction(); + return traces.length > 0; + }, + 120_000, + 10, + ); + + const traces = await getNestedFunction(); + expect(traces.length).toEqual(1); + const trace = traces[0]; + expect(trace.name).toEqual("add_negligible_value"); + expect(trace.parent_run_id).not.toBeNull(); + expect(trace.trace_id).toEqual(runId); + } catch (e) { + console.error(e); + throw e; + } finally { + await client.deleteProject({ projectName }); + } + }, +); diff --git a/src/lib/tests/ls_agent_type.test.ts b/src/lib/tests/ls_agent_type.test.ts new file mode 100644 index 0000000..519bb4d --- /dev/null +++ b/src/lib/tests/ls_agent_type.test.ts @@ -0,0 +1,67 @@ +import { RunTree } from "../run_trees.js"; +import { + NON_ROOT_LS_AGENT_TYPES, + defaultLsAgentTypeMetadata, + lsAgentTypeMetadata, + resolveDefaultLsAgentType, +} from "../utils/ls_agent_type.js"; + +const fakeRunTree = () => + ({ + extra: { metadata: {} }, + createChild: () => undefined, + postRun: () => undefined, + }) as unknown as RunTree; + +test("resolves root when no parent runtree", () => { + expect(resolveDefaultLsAgentType(undefined)).toBe("root"); + expect(resolveDefaultLsAgentType(null)).toBe("root"); +}); + +test("resolves undefined when nested under a runtree", () => { + expect(resolveDefaultLsAgentType(fakeRunTree())).toBeUndefined(); +}); + +test("resolves root for non-runtree values (e.g. ContextPlaceholder)", () => { + expect(resolveDefaultLsAgentType({})).toBe("root"); + expect(resolveDefaultLsAgentType({ notARunTree: true })).toBe("root"); +}); + +test("lsAgentTypeMetadata spreads tag when set", () => { + expect(lsAgentTypeMetadata("root")).toEqual({ ls_agent_type: "root" }); + expect(lsAgentTypeMetadata("subagent")).toEqual({ + ls_agent_type: "subagent", + }); +}); + +test("lsAgentTypeMetadata skips key when undefined", () => { + expect(lsAgentTypeMetadata(undefined)).toEqual({}); +}); + +test("defaultLsAgentTypeMetadata stamps root when no parent and key absent", () => { + expect(defaultLsAgentTypeMetadata({}, undefined)).toEqual({ + ls_agent_type: "root", + }); +}); + +test("defaultLsAgentTypeMetadata skips when nested and key absent", () => { + expect(defaultLsAgentTypeMetadata({}, fakeRunTree())).toEqual({}); +}); + +test("defaultLsAgentTypeMetadata preserves user-supplied value (returns empty delta)", () => { + expect( + defaultLsAgentTypeMetadata({ ls_agent_type: "middleware" }, undefined), + ).toEqual({}); +}); + +test("defaultLsAgentTypeMetadata preserves null opt-out (returns empty delta)", () => { + expect( + defaultLsAgentTypeMetadata({ ls_agent_type: null }, undefined), + ).toEqual({}); +}); + +test("NON_ROOT_LS_AGENT_TYPES has the three non-root tags", () => { + expect(NON_ROOT_LS_AGENT_TYPES).toEqual( + new Set(["middleware", "subagent", "compaction"]), + ); +}); diff --git a/src/lib/tests/node_fetch.int.test.ts b/src/lib/tests/node_fetch.int.test.ts new file mode 100644 index 0000000..c21bea8 --- /dev/null +++ b/src/lib/tests/node_fetch.int.test.ts @@ -0,0 +1,43 @@ +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import nodeFetch from "node-fetch"; + +import { Client } from "../client.js"; +import { + deleteProject, + waitUntilProjectFound, + skipOnRateLimit, +} from "./utils.js"; +import { traceable } from "../traceable.js"; +import { overrideFetchImplementation } from "../singletons/fetch.js"; + +test("multipart should work with overridden node-fetch", async () => { + await skipOnRateLimit(async () => { + overrideFetchImplementation(nodeFetch); + + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 6 }, + timeout_ms: 120_000, + }); + + const projectName = "__test_node_fetch" + uuidv4().substring(0, 4); + await deleteProject(langchainClient, projectName); + + await traceable( + async () => { + return "testing with node fetch"; + }, + { + project_name: projectName, + client: langchainClient, + tracingEnabled: true, + }, + )(); + + await langchainClient.awaitPendingTraceBatches(); + + await Promise.all([waitUntilProjectFound(langchainClient, projectName)]); + + await langchainClient.deleteProject({ projectName }); + }); +}); diff --git a/src/lib/tests/openai_agents_sdk.int.test.ts b/src/lib/tests/openai_agents_sdk.int.test.ts new file mode 100644 index 0000000..2e8261d --- /dev/null +++ b/src/lib/tests/openai_agents_sdk.int.test.ts @@ -0,0 +1,270 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import { describe, beforeAll, afterAll, test, expect } from "@jest/globals"; +import { + Agent, + run, + tool, + setTraceProcessors, + setTracingDisabled, + handoff, +} from "@openai/agents"; +import { z } from "zod"; +import { OpenAIAgentsTracingProcessor } from "../wrappers/openai_agents.js"; +import { Client } from "../client.js"; +import { traceable } from "../traceable.js"; +import { getCurrentRunTree } from "../singletons/traceable.js"; + +// Note: These tests require an OPENAI_API_KEY environment variable. +// They are skipped by default to avoid requiring API keys in CI. + +describe("OpenAIAgentsTracingProcessor - Real API Integration", () => { + let processor: OpenAIAgentsTracingProcessor; + let client: Client; + + beforeAll(() => { + if (!process.env.OPENAI_API_KEY) { + throw new Error("OPENAI_API_KEY environment variable is required"); + } + + client = new Client(); + + processor = new OpenAIAgentsTracingProcessor({ + client, + metadata: { + test: "openai-agents-sdk-integration", + }, + tags: ["integration-test"], + }); + + // OpenAI Agents disables tracing by default when NODE_ENV=test. + // Force-enable it for this integration test. + setTracingDisabled(false); + setTraceProcessors([processor]); + }); + + afterAll(async () => { + // Clean up and flush any pending traces + await processor.forceFlush(); + await client.awaitPendingTraceBatches(); + }); + + test("simple agent run creates trace", async () => { + const agent = new Agent({ + name: "TestAgent", + instructions: "You are a helpful assistant. Be concise.", + model: "gpt-5-nano", + }); + + const result = await run( + agent, + "Say 'Hello from LangSmith!' and nothing else.", + ); + + console.log("Result:", { + finalOutput: result.finalOutput, + lastAgent: result.lastAgent?.name, + }); + + expect(result.finalOutput).toBeDefined(); + expect(typeof result.finalOutput).toBe("string"); + }, 30000); + + test("agent with tool creates nested spans", async () => { + const weatherTool = tool({ + name: "get_weather", + description: "Get the current weather for a city", + parameters: z.object({ + city: z.string().describe("The city to get weather for"), + }), + execute: async ({ city }: { city: string }) => { + // Mock weather data + return JSON.stringify({ + city, + temperature: "72°F", + condition: "sunny", + }); + }, + }); + + const agent = new Agent({ + name: "WeatherAgent", + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + model: "gpt-5-nano", + tools: [weatherTool], + }); + + const result = await run(agent, "What's the weather in San Francisco?"); + + console.log("Weather result:", { + finalOutput: result.finalOutput, + }); + + expect(result.finalOutput).toBeDefined(); + }, 60000); + + test("agent with handoff creates handoff span", async () => { + // Create a specialist agent + const haikuAgent = new Agent({ + name: "HaikuAgent", + instructions: "You only respond in haikus (5-7-5 syllable pattern).", + model: "gpt-5-nano", + }); + + // Create a main agent that can hand off + const mainAgent = new Agent({ + name: "MainAgent", + instructions: + "You are a helpful assistant. If asked for a haiku, hand off to the HaikuAgent.", + model: "gpt-5-nano", + handoffs: [handoff(haikuAgent)], + }); + + const result = await run(mainAgent, "Write a haiku about coding."); + + console.log("Handoff result:", { + finalOutput: result.finalOutput, + lastAgent: result.lastAgent?.name, + }); + + expect(result.finalOutput).toBeDefined(); + }, 60000); + + test("streaming agent run creates trace", async () => { + const agent = new Agent({ + name: "StreamingAgent", + instructions: "You are a helpful assistant. Be concise.", + model: "gpt-5-nano", + }); + + const streamedResult = await run(agent, "Count from 1 to 5.", { + stream: true, + }); + + const events: any[] = []; + for await (const event of streamedResult) { + events.push(event); + } + + // Wait for completion + await streamedResult.completed; + + console.log("Streaming result:", { + finalOutput: streamedResult.finalOutput, + eventCount: events.length, + }); + + expect(streamedResult.finalOutput).toBeDefined(); + expect(events.length).toBeGreaterThan(0); + }, 60000); + + test("agent with custom model settings", async () => { + const agent = new Agent({ + name: "CustomAgent", + instructions: "You are a helpful assistant.", + // gpt-5-nano is a reasoning model and does not accept `temperature`, + // so use a non-reasoning model here. + model: "gpt-4.1-mini", + modelSettings: { + temperature: 0, + }, + }); + + const result = await run(agent, "Say 'test' and nothing else."); + + expect(result.finalOutput).toBeDefined(); + }, 30000); + + test("multiple runs create separate traces", async () => { + const agent = new Agent({ + name: "MultiRunAgent", + instructions: "You are a helpful assistant. Be very concise.", + model: "gpt-5-nano", + }); + + // Run multiple times + const results = await Promise.all([ + run(agent, "Say 'one'"), + run(agent, "Say 'two'"), + run(agent, "Say 'three'"), + ]); + + expect(results).toHaveLength(3); + results.forEach((result: any, idx: number) => { + console.log(`Result ${idx + 1}:`, result.finalOutput); + expect(result.finalOutput).toBeDefined(); + }); + + // Flush to ensure all traces are sent + await processor.forceFlush(); + }, 60000); + + test("traceable() called from inside an Agents tool nests under the agent run", async () => { + // A traceable helper invoked from inside a tool's execute. If context + // propagation works, its run should nest under the agent span; otherwise + // it would be a sibling root trace. + const capturedRunInfo: { + parentRunId?: string; + traceId?: string; + dottedOrder?: string; + } = {}; + + const innerTraceable = traceable( + async (city: string) => { + const currentRunTree = getCurrentRunTree(); + capturedRunInfo.parentRunId = currentRunTree.parent_run?.id; + capturedRunInfo.traceId = currentRunTree.trace_id; + capturedRunInfo.dottedOrder = currentRunTree.dotted_order; + return `mock-lookup-for-${city}`; + }, + { name: "nested_traceable_lookup", client }, + ); + + const weatherTool = tool({ + name: "get_weather", + description: "Get the current weather for a city", + parameters: z.object({ + city: z.string().describe("The city to get weather for"), + }), + execute: async ({ city }: { city: string }) => { + // Call a traceable helper from inside the tool + const lookup = await innerTraceable(city); + return JSON.stringify({ + city, + lookup, + temperature: "72°F", + condition: "sunny", + }); + }, + }); + + const agent = new Agent({ + name: "NestedTraceableAgent", + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + model: "gpt-4.1-mini", + tools: [weatherTool], + }); + + const result = await run(agent, "What's the weather in Tokyo?"); + + console.log("Nested traceable result:", { + finalOutput: result.finalOutput, + capturedRunInfo, + }); + + expect(result.finalOutput).toBeDefined(); + + // The traceable() helper must have observed a parent run, meaning + // AsyncLocalStorage context was propagated from the processor. + expect(capturedRunInfo.parentRunId).toBeDefined(); + expect(capturedRunInfo.traceId).toBeDefined(); + // Dotted order indicates a nested position in the tree, not a root. + expect(capturedRunInfo.dottedOrder?.split(".").length ?? 0).toBeGreaterThan( + 1, + ); + + await processor.forceFlush(); + }, 60000); +}); diff --git a/src/lib/tests/openai_agents_sdk.test.ts b/src/lib/tests/openai_agents_sdk.test.ts new file mode 100644 index 0000000..3354e08 --- /dev/null +++ b/src/lib/tests/openai_agents_sdk.test.ts @@ -0,0 +1,1382 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, test, expect, jest, beforeEach } from "@jest/globals"; +import { OpenAIAgentsTracingProcessor } from "../wrappers/openai_agents.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { traceable } from "../traceable.js"; +import { AsyncLocalStorageProviderSingleton } from "../singletons/traceable.js"; + +// Mock types matching @openai/agents-core/tracing +type MockSpanData = + | { + type: "agent"; + name: string; + handoffs?: string[]; + tools?: string[]; + output_type?: string; + } + | { type: "function"; name: string; input: string; output: string } + | { + type: "generation"; + name?: string; + input?: any[]; + output?: any[]; + model?: string; + model_config?: Record; + usage?: { + input_tokens?: number; + output_tokens?: number; + details?: Record | null; + }; + } + | { + type: "response"; + name?: string; + response_id?: string; + _input?: string | Record[]; + _response?: Record; + } + | { type: "handoff"; name?: string; from_agent?: string; to_agent?: string } + | { type: "guardrail"; name: string; triggered: boolean } + | { type: "custom"; name: string; data: Record }; + +interface MockSpan { + type: "trace.span"; + traceId: string; + spanId: string; + parentId: string | null; + spanData: MockSpanData; + error: { message: string; data?: Record } | null; + startedAt: string | null; + endedAt: string | null; + toJSON(): object | null; +} + +interface MockTrace { + type: "trace"; + traceId: string; + name: string; + groupId: string | null; + metadata?: Record; + toJSON(): object | null; +} + +function createMockSpan( + traceId: string, + spanId: string, + parentId: string | null, + spanData: MockSpanData, + options?: { + startedAt?: string; + endedAt?: string; + error?: { message: string; data?: Record }; + }, +): MockSpan { + return { + type: "trace.span", + traceId, + spanId, + parentId, + spanData, + error: options?.error ?? null, + startedAt: options?.startedAt ?? new Date().toISOString(), + endedAt: options?.endedAt ?? null, + toJSON: () => ({ + traceId, + spanId, + parentId, + spanData, + }), + }; +} + +function createMockTrace( + traceId: string, + name: string, + options?: { + groupId?: string; + metadata?: Record; + }, +): MockTrace { + return { + type: "trace", + traceId, + name, + groupId: options?.groupId ?? null, + metadata: options?.metadata, + toJSON: () => ({ + traceId, + name, + groupId: options?.groupId ?? null, + metadata: options?.metadata, + }), + }; +} + +describe("OpenAIAgentsTracingProcessor", () => { + let processor: OpenAIAgentsTracingProcessor; + let client: ReturnType["client"]; + let callSpy: ReturnType["callSpy"]; + + beforeEach(() => { + // onTraceStart installs the run tree via AsyncLocalStorage.enterWith(), which + // leaks into every later async task in this file: onTraceEnd's restore only + // lands when it runs on the start's async task. Without this reset, test N+1 + // nests its runs under test N's run tree and posts through test N's client. + // + // `enterWith` is absent from AsyncLocalStorageInterface (and from the mock + // storage used when tracing was never initialized), so guard on it the same + // way enterRunTreeContext() in the wrapper does. + const storage = AsyncLocalStorageProviderSingleton.getInstance(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const maybeEnterWith = (storage as any).enterWith; + if (typeof maybeEnterWith === "function") { + maybeEnterWith.call(storage, undefined); + } + const mock = mockClient(); + client = mock.client; + callSpy = mock.callSpy; + processor = new OpenAIAgentsTracingProcessor({ + client, + metadata: { custom: "metadata" }, + tags: ["test-tag"], + projectName: "test-project", + }); + }); + + describe("constructor", () => { + test("creates processor with default client", () => { + const defaultProcessor = new OpenAIAgentsTracingProcessor(); + expect(defaultProcessor).toBeDefined(); + }); + + test("accepts configuration options", () => { + const configuredProcessor = new OpenAIAgentsTracingProcessor({ + metadata: { key: "value" }, + tags: ["tag1", "tag2"], + projectName: "my-project", + name: "My Agent", + }); + expect(configuredProcessor).toBeDefined(); + }); + }); + + describe("trace lifecycle", () => { + test("handles trace start and end", async () => { + const trace = createMockTrace("trace-1", "Test Agent"); + + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + // Should have made POST and PATCH calls + const calls = callSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + }); + + test("uses custom trace name", async () => { + const customProcessor = new OpenAIAgentsTracingProcessor({ + client, + name: "Custom Workflow", + }); + + const trace = createMockTrace("trace-2", "Agent Name"); + await customProcessor.onTraceStart(trace); + await customProcessor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("Custom Workflow"))).toBe(true); + }); + + test("includes groupId as thread_id in metadata", async () => { + const trace = createMockTrace("trace-3", "Agent", { + groupId: "group-123", + }); + + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.thread_id).toBe( + "group-123", + ); + } + }); + + test("stamps default ls_agent_type=root at trace root with no user tag", async () => { + const trace = createMockTrace("trace-lst-1", "Agent"); + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.ls_agent_type).toBe("root"); + } + }); + + test.each(["root", "middleware", "subagent", "compaction"] as const)( + "preserves user-supplied ls_agent_type='%s' from trace.metadata", + async (userTag) => { + const trace = createMockTrace(`trace-lst-${userTag}`, "Agent", { + metadata: { ls_agent_type: userTag }, + }); + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.ls_agent_type).toBe( + userTag, + ); + } + }, + ); + + test("null in trace.metadata opts out; null value flows through so it overrides create_child inheritance", async () => { + const trace = createMockTrace("trace-lst-optout", "Agent", { + metadata: { ls_agent_type: null }, + }); + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.ls_agent_type).toBeNull(); + } + }); + + test("preserves other user trace.metadata alongside default root", async () => { + const trace = createMockTrace("trace-lst-other", "Agent", { + metadata: { + middleware_name: "entry_guardrail", + phase: "entry", + }, + }); + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.middleware_name).toBe( + "entry_guardrail", + ); + expect(tree.data[rootNode].extra?.metadata?.phase).toBe("entry"); + expect(tree.data[rootNode].extra?.metadata?.ls_agent_type).toBe("root"); + } + }); + + test("no default stamp when nested under a parent runtree", async () => { + const trace = createMockTrace("trace-lst-nested", "Agent"); + await traceable( + async () => { + await processor.onTraceStart(trace); + await processor.onTraceEnd(trace); + }, + { name: "parent", client }, + )(); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const agentNode = tree.nodes.find((n) => n.includes("Agent")); + if (agentNode) { + expect( + tree.data[agentNode].extra?.metadata?.ls_agent_type, + ).toBeUndefined(); + } + }); + + test("trace.metadata wins over processor metadata", async () => { + const customProcessor = new OpenAIAgentsTracingProcessor({ + client, + metadata: { env: "test" }, + }); + const trace = createMockTrace("trace-lst-prec", "Agent", { + metadata: { env: "prod" }, + }); + await customProcessor.onTraceStart(trace); + await customProcessor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.env).toBe("prod"); + } + }); + + test("trace_end preserves trace.metadata precedence over processor metadata", async () => { + const customProcessor = new OpenAIAgentsTracingProcessor({ + client, + metadata: { ls_agent_type: "root" }, + }); + const trace = createMockTrace("trace-lst-end", "Agent", { + metadata: { ls_agent_type: "middleware" }, + }); + await customProcessor.onTraceStart(trace); + await customProcessor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("Agent")); + if (rootNode) { + expect(tree.data[rootNode].extra?.metadata?.ls_agent_type).toBe( + "middleware", + ); + } + }); + }); + + describe("span handling", () => { + test("handles agent span", async () => { + const trace = createMockTrace("trace-4", "Test Agent"); + const agentSpan = createMockSpan("trace-4", "span-1", null, { + type: "agent", + name: "MyAgent", + tools: ["tool1", "tool2"], + handoffs: ["Agent2"], + output_type: "string", + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(agentSpan); + await processor.onSpanEnd(agentSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("MyAgent"))).toBe(true); + }); + + test("derives agent inputs and outputs from child response spans", async () => { + const createRunSpy = jest.spyOn(client, "createRun"); + const updateRunSpy = jest.spyOn(client, "updateRun"); + const trace = createMockTrace("trace-4b", "Test Agent"); + const agentSpan = createMockSpan("trace-4b", "span-agent", null, { + type: "agent", + name: "WeatherAgent", + tools: ["get_weather"], + output_type: "string", + }); + const firstResponseSpan = createMockSpan( + "trace-4b", + "span-resp-1", + "span-agent", + { + type: "response", + _input: "What's the weather in San Francisco?", + _response: { + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + output: [{ type: "message", content: "Calling tool..." }], + model: "gpt-4.1-mini", + }, + }, + ); + const secondResponseSpan = createMockSpan( + "trace-4b", + "span-resp-2", + "span-agent", + { + type: "response", + _input: [ + { + type: "function_call_output", + call_id: "call_123", + output: + '{"city":"San Francisco","temperature":"72°F","condition":"sunny"}', + }, + ], + _response: { + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + output: [{ type: "message", content: "It's sunny and 72°F." }], + model: "gpt-4.1-mini", + }, + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(agentSpan); + await processor.onSpanStart(firstResponseSpan); + await processor.onSpanEnd(firstResponseSpan); + await processor.onSpanStart(secondResponseSpan); + await processor.onSpanEnd(secondResponseSpan); + await processor.onSpanEnd(agentSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + expect( + createRunSpy.mock.calls.filter(([run]) => run.name === "WeatherAgent"), + ).toHaveLength(1); + expect( + updateRunSpy.mock.calls.filter( + ([, run]) => run.name === "WeatherAgent", + ), + ).toHaveLength(0); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const agentNode = tree.nodes.find((n) => n.startsWith("WeatherAgent:")); + expect(agentNode).toBeDefined(); + if (agentNode) { + expect(tree.data[agentNode].inputs).toMatchObject({ + input: "What's the weather in San Francisco?", + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + }); + expect(tree.data[agentNode].outputs).toMatchObject({ + output: [{ type: "message", content: "It's sunny and 72°F." }], + }); + } + }); + + test("handles function span (tool)", async () => { + const trace = createMockTrace("trace-5", "Test Agent"); + const functionSpan = createMockSpan("trace-5", "span-2", null, { + type: "function", + name: "get_weather", + input: '{"city": "New York"}', + output: '{"weather": "sunny"}', + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("get_weather"))).toBe(true); + }); + + test("handles generation span (LLM)", async () => { + const trace = createMockTrace("trace-6", "Test Agent"); + const generationSpan = createMockSpan("trace-6", "span-3", null, { + type: "generation", + input: [{ role: "user", content: "Hello" }], + output: [{ role: "assistant", content: "Hi there!" }], + model: "gpt-4.1-mini", + model_config: { temperature: 0.7 }, + usage: { input_tokens: 10, output_tokens: 5 }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(generationSpan); + await processor.onSpanEnd(generationSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("Generation"))).toBe(true); + }); + + test("handles response span and preserves instructions in inputs", async () => { + const trace = createMockTrace("trace-7", "Test Agent"); + const responseSpan = createMockSpan("trace-7", "span-4", null, { + type: "response", + response_id: "resp-123", + _input: "What is the weather?", + _response: { + instructions: "Answer briefly.", + model: "gpt-4.1-mini", + output: [{ type: "message", content: "It's sunny!" }], + usage: { input_tokens: 8, output_tokens: 12 }, + }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(responseSpan); + await processor.onSpanEnd(responseSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const responseNode = tree.nodes.find((n) => n.includes("Response")); + expect(responseNode).toBeDefined(); + if (responseNode) { + expect(tree.data[responseNode].inputs).toMatchObject({ + input: "What is the weather?", + instructions: "Answer briefly.", + }); + } + }); + + test("normalizes response item arrays into replayable responses-api input", async () => { + const trace = createMockTrace("trace-7b", "Test Agent"); + const responseSpan = createMockSpan("trace-7b", "span-4b", null, { + type: "response", + response_id: "resp-456", + _input: [ + { + type: "message", + role: "user", + content: "What's the weather in San Francisco?", + }, + { + type: "reasoning", + content: [], + id: "rs_123", + providerData: { type: "reasoning" }, + }, + { + type: "function_call", + name: "get_weather", + callId: "call_123", + arguments: '{"city":"San Francisco"}', + status: "completed", + id: "fc_123", + providerData: { type: "function_call" }, + }, + { + type: "function_call_result", + name: "get_weather", + callId: "call_123", + output: { + type: "text", + text: '{"city":"San Francisco","temperature":"72°F","condition":"sunny"}', + }, + status: "completed", + }, + ], + _response: { + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + model: "gpt-4.1-mini", + output: [{ type: "message", content: "It's sunny!" }], + }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(responseSpan); + await processor.onSpanEnd(responseSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const responseNode = tree.nodes.find((n) => n.includes("Response")); + expect(responseNode).toBeDefined(); + if (responseNode) { + expect(tree.data[responseNode].inputs).toMatchObject({ + input: [ + { + type: "message", + role: "user", + content: "What's the weather in San Francisco?", + }, + { + type: "reasoning", + id: "rs_123", + content: [], + }, + { + type: "function_call", + id: "fc_123", + call_id: "call_123", + name: "get_weather", + arguments: '{"city":"San Francisco"}', + }, + { + type: "function_call_output", + call_id: "call_123", + output: + '{"city":"San Francisco","temperature":"72°F","condition":"sunny"}', + }, + ], + instructions: + "You are a weather assistant. Use the get_weather tool when asked about weather.", + }); + } + }); + + test("handles handoff span", async () => { + const trace = createMockTrace("trace-8", "Test Agent"); + const handoffSpan = createMockSpan("trace-8", "span-5", null, { + type: "handoff", + from_agent: "Agent1", + to_agent: "Agent2", + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(handoffSpan); + await processor.onSpanEnd(handoffSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const handoffNode = tree.nodes.find((n) => n.includes("Handoff")); + expect(handoffNode).toBeDefined(); + if (handoffNode) { + expect(tree.data[handoffNode].inputs).toMatchObject({ + from_agent: "Agent1", + }); + expect(tree.data[handoffNode].outputs).toMatchObject({ + to_agent: "Agent2", + }); + } + }); + + test("handles guardrail span", async () => { + const trace = createMockTrace("trace-9", "Test Agent"); + const guardrailSpan = createMockSpan("trace-9", "span-6", null, { + type: "guardrail", + name: "safety_check", + triggered: true, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(guardrailSpan); + await processor.onSpanEnd(guardrailSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("safety_check"))).toBe(true); + }); + + test("handles custom span", async () => { + const trace = createMockTrace("trace-10", "Test Agent"); + const customSpan = createMockSpan("trace-10", "span-7", null, { + type: "custom", + name: "custom_operation", + data: { key: "value", count: 42 }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(customSpan); + await processor.onSpanEnd(customSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("custom_operation"))).toBe(true); + }); + }); + + describe("nested spans", () => { + test("handles parent-child span relationships", async () => { + const trace = createMockTrace("trace-11", "Test Agent"); + + // Parent span (agent) + const parentSpan = createMockSpan("trace-11", "span-parent", null, { + type: "agent", + name: "ParentAgent", + }); + + // Child span (function) + const childSpan = createMockSpan( + "trace-11", + "span-child", + "span-parent", + { + type: "function", + name: "child_tool", + input: "{}", + output: "{}", + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(parentSpan); + await processor.onSpanStart(childSpan); + await processor.onSpanEnd(childSpan); + await processor.onSpanEnd(parentSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + // Should have edges between parent and child + expect(tree.edges.length).toBeGreaterThan(0); + }); + + test("marks agent spans under function spans as subagents", async () => { + const trace = createMockTrace("trace-11b", "Test Agent"); + + const functionSpan = createMockSpan("trace-11b", "span-fn", null, { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }); + + const subagentSpan = createMockSpan( + "trace-11b", + "span-agent", + "span-fn", + { + type: "agent", + name: "SubAgent", + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanStart(subagentSpan); + await processor.onSpanEnd(subagentSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const subagentNode = tree.nodes.find((n) => n.includes("SubAgent")); + expect(subagentNode).toBeDefined(); + if (subagentNode) { + expect(tree.data[subagentNode].extra?.metadata?.ls_agent_type).toBe( + "subagent", + ); + } + }); + + test("subagent detection overrides inherited root at agent-as-tool site", async () => { + const trace = createMockTrace("trace-11d", "Test Agent", { + metadata: { ls_agent_type: "root" }, + }); + const functionSpan = createMockSpan("trace-11d", "span-fn-d", null, { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }); + const subagentSpan = createMockSpan( + "trace-11d", + "span-agent-d", + "span-fn-d", + { type: "agent", name: "SubAgentD" }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanStart(subagentSpan); + await processor.onSpanEnd(subagentSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const subagentNode = tree.nodes.find((n) => n.includes("SubAgentD")); + if (subagentNode) { + expect(tree.data[subagentNode].extra?.metadata?.ls_agent_type).toBe( + "subagent", + ); + } + }); + + test.each(["middleware", "compaction"] as const)( + "subagent detection preserves user-supplied '%s' at agent-as-tool site", + async (userTag) => { + const trace = createMockTrace(`trace-11e-${userTag}`, "Test Agent", { + metadata: { ls_agent_type: userTag }, + }); + const functionSpan = createMockSpan( + `trace-11e-${userTag}`, + `span-fn-e-${userTag}`, + null, + { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }, + ); + const subagentSpan = createMockSpan( + `trace-11e-${userTag}`, + `span-agent-e-${userTag}`, + `span-fn-e-${userTag}`, + { type: "agent", name: `SubAgentE_${userTag}` }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanStart(subagentSpan); + await processor.onSpanEnd(subagentSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const subagentNode = tree.nodes.find((n) => + n.includes(`SubAgentE_${userTag}`), + ); + if (subagentNode) { + expect(tree.data[subagentNode].extra?.metadata?.ls_agent_type).toBe( + userTag, + ); + } + }, + ); + + test("subagent detection is idempotent on existing subagent tag", async () => { + const trace = createMockTrace("trace-11f-idem", "Test Agent", { + metadata: { ls_agent_type: "subagent" }, + }); + const functionSpan = createMockSpan( + "trace-11f-idem", + "span-fn-idem", + null, + { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }, + ); + const subagentSpan = createMockSpan( + "trace-11f-idem", + "span-agent-idem", + "span-fn-idem", + { type: "agent", name: "SubAgentIdem" }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanStart(subagentSpan); + await processor.onSpanEnd(subagentSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const subagentNode = tree.nodes.find((n) => n.includes("SubAgentIdem")); + if (subagentNode) { + expect(tree.data[subagentNode].extra?.metadata?.ls_agent_type).toBe( + "subagent", + ); + } + }); + + test("tags an agent as a subagent when the tool is further above it", async () => { + // When an agent runs as a tool, an extra run sits between the tool and + // the agent. + const outerTrace = createMockTrace("trace-nested-outer", "Outer"); + const functionSpan = createMockSpan( + "trace-nested-outer", + "span-fn-nested", + null, + { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }, + ); + const innerTrace = createMockTrace("trace-nested-inner", "Inner"); + const subagentSpan = createMockSpan( + "trace-nested-inner", + "span-agent-nested", + null, + { type: "agent", name: "NestedSubAgent" }, + ); + + await processor.onTraceStart(outerTrace); + await processor.onSpanStart(functionSpan); + // The nested run is created as a child of the tool run. + await processor.onTraceStart(innerTrace); + await processor.onSpanStart(subagentSpan); + await processor.onSpanEnd(subagentSpan); + await processor.onTraceEnd(innerTrace); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(outerTrace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const subagentNode = tree.nodes.find((n) => n.includes("NestedSubAgent")); + expect(subagentNode).toBeDefined(); + if (subagentNode) { + expect(tree.data[subagentNode].extra?.metadata?.ls_agent_type).toBe( + "subagent", + ); + } + }); + + test("tags an agent handed off to inside a tool as a subagent", async () => { + // A handoff opens a new agent run beside the one it replaced, so the + // tool is still above it. + const outerTrace = createMockTrace("trace-handoff-sub", "Outer"); + const functionSpan = createMockSpan( + "trace-handoff-sub", + "span-fn-handoff-sub", + null, + { + type: "function", + name: "invoke_subagent", + input: "{}", + output: "{}", + }, + ); + const innerTrace = createMockTrace("trace-handoff-sub-inner", "Inner"); + const firstAgentSpan = createMockSpan( + "trace-handoff-sub-inner", + "span-agent-first", + null, + { type: "agent", name: "FirstAgent" }, + ); + const handedOffAgentSpan = createMockSpan( + "trace-handoff-sub-inner", + "span-agent-handed-off", + null, + { type: "agent", name: "HandedOffAgent" }, + ); + + await processor.onTraceStart(outerTrace); + await processor.onSpanStart(functionSpan); + await processor.onTraceStart(innerTrace); + await processor.onSpanStart(firstAgentSpan); + await processor.onSpanEnd(firstAgentSpan); + await processor.onSpanStart(handedOffAgentSpan); + await processor.onSpanEnd(handedOffAgentSpan); + await processor.onTraceEnd(innerTrace); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(outerTrace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const handedOffNode = tree.nodes.find((n) => + n.includes("HandedOffAgent"), + ); + expect(handedOffNode).toBeDefined(); + if (handedOffNode) { + expect(tree.data[handedOffNode].extra?.metadata?.ls_agent_type).toBe( + "subagent", + ); + } + }); + + test("stamps guardrail spans as middleware", async () => { + const trace = createMockTrace("trace-guard-1", "Test Agent"); + const guardrailSpan = createMockSpan( + "trace-guard-1", + "span-guard", + null, + { + type: "guardrail", + name: "entry_guardrail", + triggered: false, + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(guardrailSpan); + await processor.onSpanEnd(guardrailSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const guardNode = tree.nodes.find((n) => n.includes("entry_guardrail")); + expect(guardNode).toBeDefined(); + if (guardNode) { + expect(tree.data[guardNode].extra?.metadata?.ls_agent_type).toBe( + "middleware", + ); + } + }); + + test("guardrail middleware stamp overrides inherited root", async () => { + const trace = createMockTrace("trace-guard-2", "Test Agent", { + metadata: { ls_agent_type: "root" }, + }); + const guardrailSpan = createMockSpan( + "trace-guard-2", + "span-guard-2", + null, + { + type: "guardrail", + name: "exit_guardrail", + triggered: false, + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(guardrailSpan); + await processor.onSpanEnd(guardrailSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const guardNode = tree.nodes.find((n) => n.includes("exit_guardrail")); + expect(guardNode).toBeDefined(); + if (guardNode) { + expect(tree.data[guardNode].extra?.metadata?.ls_agent_type).toBe( + "middleware", + ); + } + }); + + test.each(["middleware", "subagent", "compaction"] as const)( + "guardrail keeps a user-supplied '%s' tag", + async (userTag) => { + const trace = createMockTrace( + `trace-guard-3-${userTag}`, + "Test Agent", + { + metadata: { ls_agent_type: userTag }, + }, + ); + const guardrailSpan = createMockSpan( + `trace-guard-3-${userTag}`, + `span-guard-3-${userTag}`, + null, + { + type: "guardrail", + name: `named_guardrail_${userTag}`, + triggered: false, + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(guardrailSpan); + await processor.onSpanEnd(guardrailSpan); + await processor.onTraceEnd(trace); + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const guardNode = tree.nodes.find((n) => + n.includes(`named_guardrail_${userTag}`), + ); + expect(guardNode).toBeDefined(); + if (guardNode) { + expect(tree.data[guardNode].extra?.metadata?.ls_agent_type).toBe( + userTag, + ); + } + }, + ); + + test("does not mark handoff agents as subagents", async () => { + const trace = createMockTrace("trace-11c", "Test Agent"); + + const functionSpan = createMockSpan("trace-11c", "span-fn", null, { + type: "function", + name: "invoke_handoff", + input: "{}", + output: "{}", + }); + + const handoffSpan = createMockSpan( + "trace-11c", + "span-handoff", + "span-fn", + { + type: "handoff", + from_agent: "ParentAgent", + to_agent: "HandoffAgent", + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(functionSpan); + await processor.onSpanStart(handoffSpan); + await processor.onSpanEnd(handoffSpan); + await processor.onSpanEnd(functionSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const handoffNode = tree.nodes.find((n) => n.includes("Handoff")); + expect(handoffNode).toBeDefined(); + if (handoffNode) { + expect(tree.data[handoffNode].extra?.metadata?.ls_agent_type).not.toBe( + "subagent", + ); + } + }); + + test("posts root trace on trace end when no response or generation spans occur", async () => { + const trace = createMockTrace("trace-11d", "No Response Trace"); + const agentSpan = createMockSpan("trace-11d", "span-agent-only", null, { + type: "agent", + name: "AgentOnly", + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(agentSpan); + await processor.onSpanEnd(agentSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const rootNode = tree.nodes.find((n) => n.includes("No Response Trace")); + expect(rootNode).toBeDefined(); + if (rootNode) { + expect(tree.data[rootNode].inputs).toEqual({}); + } + }); + + test("gracefully skips orphan spans with missing parents", async () => { + const trace = createMockTrace("trace-11e", "Orphan Trace"); + const orphanSpan = createMockSpan( + "trace-11e", + "span-orphan", + "missing-parent", + { + type: "function", + name: "orphan_tool", + input: "{}", + output: "{}", + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(orphanSpan); + await processor.onSpanEnd(orphanSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes.some((n) => n.includes("orphan_tool"))).toBe(false); + expect(tree.nodes.some((n) => n.includes("Orphan Trace"))).toBe(true); + }); + + test("nested traceable called inside a function (tool) span nests under the function span", async () => { + const trace = createMockTrace("trace-11f", "Agent With Traceable"); + const agentSpan = createMockSpan( + "trace-11f", + "span-agent-traceable", + null, + { + type: "agent", + name: "TraceableParentAgent", + }, + ); + const functionSpan = createMockSpan( + "trace-11f", + "span-fn-traceable", + "span-agent-traceable", + { + type: "function", + name: "get_weather", + input: '{"city":"Tokyo"}', + output: "{}", + }, + ); + + const innerTraceable = traceable( + async (city: string) => { + return `mock-${city}`; + }, + { name: "nested_traceable", client }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(agentSpan); + await processor.onSpanStart(functionSpan); + + // Simulate a traceable() call happening during tool execution + // between the function span's onSpanStart and onSpanEnd. + const result = await innerTraceable("Tokyo"); + expect(result).toBe("mock-Tokyo"); + + await processor.onSpanEnd(functionSpan); + await processor.onSpanEnd(agentSpan); + await processor.onTraceEnd(trace); + + // traceable() schedules its POST/PATCH via fire-and-forget promise + // chains. Drain the microtask queue a few times and wait for any + // pending batches so all run bodies make it into callSpy. + for (let i = 0; i < 5; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await client.awaitPendingTraceBatches(); + } + + const postedRuns = (callSpy.mock.calls as unknown[][]) + .map((call): Record | undefined => { + const [url, fetchArgs] = call.slice(-2) as [ + string, + { method?: string; body?: string | Uint8Array } | undefined, + ]; + if (!fetchArgs || fetchArgs.method !== "POST") return undefined; + if (!url.includes("/runs")) return undefined; + const body = fetchArgs.body; + let decoded: string | undefined; + if (typeof body === "string") { + decoded = body; + } else if (body instanceof Uint8Array) { + decoded = new TextDecoder().decode(body); + } + if (!decoded || !decoded.trim().startsWith("{")) return undefined; + try { + return JSON.parse(decoded) as Record; + } catch { + return undefined; + } + }) + .filter((run): run is Record => run != null); + + const nestedRun = postedRuns.find( + (run) => run.name === "nested_traceable", + ); + const toolRun = postedRuns.find((run) => run.name === "get_weather"); + expect(nestedRun).toBeDefined(); + expect(toolRun).toBeDefined(); + expect(nestedRun?.parent_run_id).toBe(toolRun?.id); + }); + }); + + describe("error handling", () => { + test("captures span errors", async () => { + const trace = createMockTrace("trace-12", "Test Agent"); + const errorSpan = createMockSpan( + "trace-12", + "span-error", + null, + { + type: "function", + name: "failing_tool", + input: "{}", + output: "", + }, + { + error: { message: "Tool execution failed" }, + }, + ); + + await processor.onTraceStart(trace); + await processor.onSpanStart(errorSpan); + await processor.onSpanEnd(errorSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + // Check that error was captured + const calls = callSpy.mock.calls; + const patchCall = calls.find((call: any[]) => { + const url = call[0] as string; + return url.includes("/runs/") && (call[1] as any)?.method === "PATCH"; + }); + + if (patchCall) { + const body = JSON.parse((patchCall[1] as any).body); + expect(body.error).toBe("Tool execution failed"); + } + }); + }); + + describe("usage metadata", () => { + test("extracts usage from generation span (details shape)", async () => { + const trace = createMockTrace("trace-13", "Test Agent"); + const generationSpan = createMockSpan("trace-13", "span-gen", null, { + type: "generation", + model: "gpt-4.1-mini", + usage: { + input_tokens: 100, + output_tokens: 50, + details: { + cached_tokens: 20, + reasoning_tokens: 10, + audio_tokens: 5, + }, + }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(generationSpan); + await processor.onSpanEnd(generationSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const generationNode = tree.nodes.find((n) => n.includes("Generation")); + expect(generationNode).toBeDefined(); + expect( + tree.data[generationNode!].extra?.metadata?.usage_metadata, + ).toEqual({ + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + input_token_details: { cache_read: 20, audio: 5 }, + output_token_details: { reasoning: 10 }, + }); + }); + + test("extracts usage from response span (Responses API shape)", async () => { + const trace = createMockTrace("trace-13b", "Test Agent"); + const responseSpan = createMockSpan("trace-13b", "span-resp", null, { + type: "response", + response_id: "resp-usage", + _input: "hello", + _response: { + model: "gpt-4.1-mini", + output: [{ type: "message", content: "hi" }], + usage: { + input_tokens: 80, + output_tokens: 40, + total_tokens: 120, + input_tokens_details: { cached_tokens: 30 }, + output_tokens_details: { reasoning_tokens: 7 }, + }, + }, + }); + + await processor.onTraceStart(trace); + await processor.onSpanStart(responseSpan); + await processor.onSpanEnd(responseSpan); + await processor.onTraceEnd(trace); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const responseNode = tree.nodes.find((n) => n.includes("Response")); + expect(responseNode).toBeDefined(); + expect(tree.data[responseNode!].extra?.metadata?.usage_metadata).toEqual({ + input_tokens: 80, + output_tokens: 40, + total_tokens: 120, + input_token_details: { cache_read: 30 }, + output_token_details: { reasoning: 7 }, + }); + }); + }); + + describe("shutdown and flush", () => { + test("forceFlush calls client flush", async () => { + const flushSpy = jest.spyOn(client, "flush").mockResolvedValue(); + + await processor.forceFlush(); + + expect(flushSpy).toHaveBeenCalled(); + flushSpy.mockRestore(); + }); + + test("shutdown calls client flush", async () => { + const flushSpy = jest.spyOn(client, "flush").mockResolvedValue(); + + await processor.shutdown(); + + expect(flushSpy).toHaveBeenCalled(); + flushSpy.mockRestore(); + }); + }); +}); diff --git a/src/lib/tests/otel_translator.vitesttest.ts b/src/lib/tests/otel_translator.vitesttest.ts new file mode 100644 index 0000000..9488f28 --- /dev/null +++ b/src/lib/tests/otel_translator.vitesttest.ts @@ -0,0 +1,495 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { LangSmithToOTELTranslator } from "../experimental/otel/translator.js"; +import * as otelSingleton from "../singletons/otel.js"; + +// Minimal span mock that records everything set on it +function makeSpanMock() { + const attributes: Record = {}; + const statusHistory: { code: number; message?: string }[] = []; + const exceptions: (Error | string)[] = []; + let endTime: number | Date | undefined; + + return { + setAttribute: vi.fn((key: string, value: any) => { + attributes[key] = value; + }), + setStatus: vi.fn((status: { code: number; message?: string }) => { + statusHistory.push(status); + }), + recordException: vi.fn((e: Error | string) => { + exceptions.push(e); + }), + end: vi.fn((t?: number | Date) => { + endTime = t; + }), + // Inspection helpers + _attributes: attributes, + _statusHistory: statusHistory, + _exceptions: exceptions, + get _endTime() { + return endTime; + }, + }; +} + +// A minimal OTELContext stand-in — the translator treats it as opaque +const DUMMY_CONTEXT = {} as any; + +function makeTranslator(span: ReturnType) { + vi.spyOn(otelSingleton, "getOTELTrace").mockReturnValue({ + getSpan: vi.fn().mockReturnValue(span), + } as any); + + return new LangSmithToOTELTranslator(); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +// --------------------------------------------------------------------------- +// Helpers to build minimal RunCreate / RunUpdate payloads +// --------------------------------------------------------------------------- +function makeRunCreate(overrides: Record = {}) { + return { + id: "run-1", + trace_id: "trace-1", + name: "test-run", + run_type: "llm", + inputs: { messages: [{ role: "user", content: "hi" }], model: "gpt-4o" }, + start_time: new Date().toISOString(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("exportBatch — post operation", () => { + it("sets basic span attributes from a RunCreate", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: new Date().toISOString() }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.setAttribute).toHaveBeenCalledWith( + "langsmith.span.kind", + "llm", + ); + expect(span.setAttribute).toHaveBeenCalledWith( + "gen_ai.operation.name", + "chat", + ); + expect(span.setAttribute).toHaveBeenCalledWith( + "langsmith.trace.name", + "test-run", + ); + }); + + it("ends the span when end_time is present", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + const endTime = new Date().toISOString(); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: endTime }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.end).toHaveBeenCalledWith(new Date(endTime)); + }); + + it("does NOT end the span when end_time is absent", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: undefined }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.end).not.toHaveBeenCalled(); + }); + + it("sets OK status when there is no error", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: new Date().toISOString() }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.setStatus).toHaveBeenCalledWith({ code: 1 }); + }); + + it("sets ERROR status and records exception when run has error", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + error: "Something went wrong", + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.setStatus).toHaveBeenCalledWith({ code: 2 }); + expect(span.recordException).toHaveBeenCalledWith( + new Error("Something went wrong"), + ); + }); + + it("skips gracefully when context has no active span", () => { + vi.spyOn(otelSingleton, "getOTELTrace").mockReturnValue({ + getSpan: vi.fn().mockReturnValue(undefined), + } as any); + const translator = new LangSmithToOTELTranslator(); + + // Should not throw + expect(() => + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: new Date().toISOString() }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ), + ).not.toThrow(); + }); +}); + +describe("exportBatch — patch operation", () => { + it("updates and ends an existing span on patch", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + const endTime = new Date().toISOString(); + + // First create the span (no end_time so it stays open) + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ end_time: undefined }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span.end).not.toHaveBeenCalled(); + + // Then patch it with an end_time + translator.exportBatch( + [ + { + operation: "patch", + id: "run-1", + trace_id: "trace-1", + run: { end_time: endTime }, + }, + ], + new Map(), + ); + + expect(span.end).toHaveBeenCalledWith(new Date(endTime)); + }); +}); + +describe("usage_metadata", () => { + it("sets langsmith.usage_metadata from extra.metadata", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + const usageMetadata = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }; + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + extra: { metadata: { usage_metadata: usageMetadata } }, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span._attributes["langsmith.usage_metadata"]).toBe( + JSON.stringify(usageMetadata), + ); + }); + + it("also copies usage_metadata into langsmith.metadata.usage_metadata via the metadata loop", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + const usageMetadata = { + input_tokens: 10, + output_tokens: 5, + total_tokens: 15, + }; + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + extra: { metadata: { usage_metadata: usageMetadata } }, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span._attributes["langsmith.metadata.usage_metadata"]).toBe( + JSON.stringify(usageMetadata), + ); + }); +}); + +describe("metadata attributes", () => { + it("writes arbitrary metadata keys under langsmith.metadata.*", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + extra: { + metadata: { + ls_model_name: "gpt-4o", + custom_tag: "foo", + complex: { nested: { value: "test" } }, + }, + }, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span._attributes["langsmith.metadata.ls_model_name"]).toBe("gpt-4o"); + expect(span._attributes["langsmith.metadata.custom_tag"]).toBe("foo"); + expect(span._attributes["langsmith.metadata.complex"]).toBe( + JSON.stringify({ nested: { value: "test" } }), + ); + }); +}); + +describe("token counts from outputs", () => { + it("extracts token counts from outputs.usage_metadata", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + outputs: { + usage_metadata: { input_tokens: 200, output_tokens: 80 }, + }, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span._attributes["gen_ai.usage.input_tokens"]).toBe(200); + expect(span._attributes["gen_ai.usage.output_tokens"]).toBe(80); + expect(span._attributes["gen_ai.usage.total_tokens"]).toBe(280); + }); + + it("overrides usage from metadata if present in outputs", () => { + const span = makeSpanMock(); + const translator = makeTranslator(span); + + translator.exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + outputs: { + // Explicitly set usage in outputs + usage_metadata: { + input_tokens: 200, + output_tokens: 80, + total_tokens: 280, + }, + }, + extra: { + metadata: { + usage_metadata: { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }, + }, + }, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + + expect(span._attributes["langsmith.usage_metadata"]).toBe( + JSON.stringify({ + input_tokens: 200, + output_tokens: 80, + total_tokens: 280, + }), + ); + + expect(span._attributes["gen_ai.usage.input_tokens"]).toBe(200); + expect(span._attributes["gen_ai.usage.output_tokens"]).toBe(80); + expect(span._attributes["gen_ai.usage.total_tokens"]).toBe(280); + }); +}); + +function attributesForRun(overrides: Record) { + const span = makeSpanMock(); + makeTranslator(span).exportBatch( + [ + { + operation: "post", + id: "run-1", + trace_id: "trace-1", + run: makeRunCreate({ + end_time: new Date().toISOString(), + ...overrides, + }), + }, + ], + new Map([["run-1", DUMMY_CONTEXT]]), + ); + return span._attributes; +} + +describe("tool attributes", () => { + it("sets tool name and call id on tool runs", () => { + const attrs = attributesForRun({ + run_type: "tool", + name: "Bash", + extra: { metadata: { tool_call_id: "toolu_123" } }, + }); + + expect(attrs["gen_ai.tool.name"]).toBe("Bash"); + expect(attrs["gen_ai.tool.call.id"]).toBe("toolu_123"); + }); + + it("prefers the top-level tool call id over metadata", () => { + const attrs = attributesForRun({ + run_type: "tool", + extra: { + tool_call_id: "toolu_extra", + metadata: { tool_call_id: "toolu_metadata" }, + }, + }); + + expect(attrs["gen_ai.tool.call.id"]).toBe("toolu_extra"); + }); + + it("omits tool name and call id on non-tool runs", () => { + const attrs = attributesForRun({ + name: "Bash", + extra: { metadata: { tool_call_id: "toolu_123" } }, + }); + + expect(attrs).not.toHaveProperty("gen_ai.tool.name"); + expect(attrs).not.toHaveProperty("gen_ai.tool.call.id"); + }); + + it("passes tool definitions through in provider shapes", () => { + const tools = [ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather", + parameters: { type: "object", properties: {} }, + }, + }, + { name: "search", description: "Search", input_schema: {} }, + { type: "tool_search" }, + ]; + const attrs = attributesForRun({ extra: { invocation_params: { tools } } }); + + expect(JSON.parse(attrs["gen_ai.tool.definitions"])).toEqual(tools); + }); + + it.each([ + {}, + { invocation_params: {} }, + { invocation_params: { tools: [] } }, + ])("omits tool definitions without tools (%j)", (extra) => { + expect(attributesForRun({ extra })).not.toHaveProperty( + "gen_ai.tool.definitions", + ); + }); +}); diff --git a/src/lib/tests/perf.int.test.ts b/src/lib/tests/perf.int.test.ts new file mode 100644 index 0000000..f68a0ec --- /dev/null +++ b/src/lib/tests/perf.int.test.ts @@ -0,0 +1,393 @@ +/* eslint-disable no-process-env */ +import { traceable } from "../traceable.js"; +import { Client } from "../client.js"; +import { v7 as uuidv7 } from "../utils/uuid/src/index.js"; + +import * as fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +test("Test performance with large runs and concurrency", async () => { + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "beemovie.txt", + ); + + const largeInput = { bee: fs.readFileSync(pathname).toString() }; + const client = new Client({ + debug: true, + }); + const largeTest = traceable( + async (foo: { bee: string }) => { + await new Promise((resolve) => setTimeout(resolve, 100)); + return { reversebee: foo.bee.toString().split("").reverse().join("") }; + }, + { + client, + }, + ); + + await Promise.all( + Array.from({ length: 1000 }, async () => { + await largeTest(largeInput); + }), + ); + + await client.awaitPendingTraceBatches(); +}); + +/** + * Benchmarks event-loop blocking caused by tracing large payloads. + * + * Two payload shapes are measured: + * 1. Large-string ("base64"): payload dominated by a few multi-hundred-KB + * strings. This is the shape the worker-offload path is optimized for. + * 2. Structural: payload whose bulk is many small strings across a wide / + * nested object graph. `hasLargeString` should return false here and + * the client should fall back to synchronous serialize at flush. This + * bench protects against regressions in that non-offloaded path. + * + * Each bench writes its machine-readable results to a JSON file under + * `$LANGSMITH_PERF_BENCH_DIR` (defaults to the repo-local `js/` directory). + * The js-perf CI workflow reads those files from both `main` and PR HEAD + * and posts a comparison comment. + * + * Benchmarks are skipped by default and run only when + * LANGSMITH_RUN_PERF_BENCH=true. + * + * Manual run: + * LANGSMITH_RUN_PERF_BENCH=true LANGSMITH_TRACING=false \ + * pnpm test:integration src/tests/perf.int.test.ts -t "benchmark" + */ +// Enabled by setting LANGSMITH_RUN_PERF_BENCH=true. Skipped in CI by default. +const benchIt = + process.env.LANGSMITH_RUN_PERF_BENCH === "true" ? test : test.skip; + +interface BenchStats { + total: number; + max: number; + p50: number; + p95: number; + p99: number; +} + +interface BenchResult { + name: string; + runs: number; + inputBytes: number; + outputBytes: number; + wallMs: number; + createRun: BenchStats; + updateRun: BenchStats; + loopLag: BenchStats & { samples: number }; +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + return values[Math.min(values.length - 1, Math.floor(values.length * p))]; +} + +function stats(values: number[]): BenchStats { + return { + total: values.reduce((a, b) => a + b, 0), + max: values.length ? values[values.length - 1] : 0, + p50: percentile(values, 0.5), + p95: percentile(values, 0.95), + p99: percentile(values, 0.99), + }; +} + +function benchOutputPath(filename: string): string { + const dir = process.env.LANGSMITH_PERF_BENCH_DIR ?? process.cwd(); + return path.join(dir, filename); +} + +// Shared mock fetch: /info returns a batch-ingest config; everything else +// returns an empty 202. Keeps the bench end-to-end without hitting network. +const benchFetch: typeof fetch = async (input) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + if (url.endsWith("/info")) { + return new Response( + JSON.stringify({ + batch_ingest_config: { + use_multipart_endpoint: true, + size_limit: 100, + size_limit_bytes: 20 * 1024 * 1024, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response(JSON.stringify({ ok: true }), { + status: 202, + headers: { "content-type": "application/json" }, + }); +}; + +async function runBench(opts: { + name: string; + inputs: unknown; + outputs: unknown; + numRuns: number; + outFile: string; +}): Promise { + const { name, inputs, outputs, numRuns, outFile } = opts; + const inputJsonSize = JSON.stringify(inputs).length; + const outputJsonSize = JSON.stringify(outputs).length; + + const targetInterval = 1; + const lags: number[] = []; + let lastTick = performance.now(); + const monitor = setInterval(() => { + const now = performance.now(); + const lag = now - lastTick - targetInterval; + if (lag > 0) lags.push(lag); + lastTick = now; + }, targetInterval); + monitor.unref(); + + const client = new Client({ + autoBatchTracing: true, + fetchImplementation: benchFetch, + fetchOptions: { signal: AbortSignal.timeout(30_000) }, + }); + + const createTimes: number[] = []; + const updateTimes: number[] = []; + const wallStart = performance.now(); + + for (let i = 0; i < numRuns; i++) { + const runId = uuidv7(); + + const createStart = performance.now(); + await client.createRun({ + id: runId, + trace_id: runId, + dotted_order: `${new Date().toISOString()}${runId}`, + name: "bench_run", + run_type: "llm", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: inputs as any, + start_time: Date.now(), + }); + createTimes.push(performance.now() - createStart); + + await new Promise((resolve) => setImmediate(resolve)); + + const updateStart = performance.now(); + await client.updateRun(runId, { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputs: outputs as any, + end_time: Date.now(), + }); + updateTimes.push(performance.now() - updateStart); + + await new Promise((resolve) => setImmediate(resolve)); + } + + await client.awaitPendingTraceBatches(); + + const wallEnd = performance.now(); + clearInterval(monitor); + + lags.sort((a, b) => a - b); + createTimes.sort((a, b) => a - b); + updateTimes.sort((a, b) => a - b); + + const createStats = stats(createTimes); + const updateStats = stats(updateTimes); + const lagStats = stats(lags); + const result: BenchResult = { + name, + runs: numRuns, + inputBytes: inputJsonSize, + outputBytes: outputJsonSize, + wallMs: wallEnd - wallStart, + createRun: createStats, + updateRun: updateStats, + loopLag: { ...lagStats, samples: lags.length }, + }; + + const fmt = (n: number) => n.toFixed(2).padStart(9); + const humanReadable = [ + ``, + `=== Event loop benchmark: ${name} ===`, + `Runs traced: ${numRuns}`, + `Per-run create payload: ${(inputJsonSize / 1024).toFixed(1)} KB`, + `Per-run update payload: ${(outputJsonSize / 1024).toFixed(1)} KB`, + `Wall time (incl. drain): ${(wallEnd - wallStart).toFixed(1)} ms`, + ``, + ` total max p50 p95 p99`, + `createRun ${fmt(createStats.total)} ${fmt(createStats.max)} ${fmt( + createStats.p50, + )} ${fmt(createStats.p95)} ${fmt(createStats.p99)}`, + `updateRun ${fmt(updateStats.total)} ${fmt(updateStats.max)} ${fmt( + updateStats.p50, + )} ${fmt(updateStats.p95)} ${fmt(updateStats.p99)}`, + `loop lag ${fmt(lagStats.total)} ${fmt(lagStats.max)} ${fmt( + lagStats.p50, + )} ${fmt(lagStats.p95)} ${fmt(lagStats.p99)}`, + `(loop lag monitor: ${targetInterval}ms target, ${lags.length} samples > 0)`, + ].join("\n"); + console.log(humanReadable); + + fs.writeFileSync(benchOutputPath(outFile), JSON.stringify(result, null, 2)); + return result; +} + +benchIt( + "benchmark event loop blocking from tracing large base64-heavy payloads", + async () => { + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "test_data", + "beemovie.txt", + ); + + // Build a realistically large payload. Real-world LangSmith payloads + // are frequently dominated by inlined base64 image data in OpenAI-style + // message content parts -- exactly the shape that structuredClone / + // worker transfer handles best. We therefore construct a payload that + // matches that shape. + const baseText = fs.readFileSync(pathname).toString(); + const base64Chunk = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + // ~500KB base64 image per message + const imageB64 = base64Chunk.repeat( + Math.ceil((500 * 1024) / base64Chunk.length), + ); + const dataUri = `data:image/png;base64,${imageB64}`; + const largeInputs = { + messages: Array.from({ length: 5 }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + content: [ + { type: "text", text: baseText.slice(0, 2000) }, + { type: "image_url", image_url: { url: dataUri, detail: "high" } }, + ], + })), + model: "gpt-4o", + temperature: 0.7, + }; + const largeOutputs = { + choices: [ + { + message: { + role: "assistant", + content: baseText.slice(0, 5000), + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 120000, + completion_tokens: 48000, + }, + }; + + await runBench({ + name: "base64", + inputs: largeInputs, + outputs: largeOutputs, + numRuns: 100, + outFile: "bench-base64.json", + }); + }, + 120_000, +); + +benchIt( + "benchmark event loop blocking from tracing structural large payloads", + async () => { + // Structural-large payload: large total JSON size, but made up of many + // small-to-medium strings across a wide/nested object graph. No single + // string exceeds the worker-offload threshold, so this exercises the + // *non-offloaded* flush path (sync serialize on the main thread). Guards + // against regressions in the fallback path and confirms hasLargeString + // correctly filters this shape out. + const NUM_MESSAGES = 200; + const PER_MESSAGE_CHARS = 6_000; // well below the 64KB worker threshold + + // Distinct-ish content per message so V8 can't aggressively dedupe. + const makeText = (seed: number, chars: number) => { + const base = `msg-${seed} ` + "lorem ipsum dolor sit amet ".repeat(40); + return base.repeat(Math.ceil(chars / base.length)).slice(0, chars); + }; + + const structuralInputs = { + messages: Array.from({ length: NUM_MESSAGES }, (_, i) => ({ + role: i % 2 === 0 ? "user" : "assistant", + name: `speaker_${i}`, + timestamp: new Date( + Date.now() - (NUM_MESSAGES - i) * 1000, + ).toISOString(), + metadata: { + turn: i, + tokens: 200 + (i % 50), + tags: [`tag-${i % 7}`, `tag-${i % 11}`, `tag-${i % 13}`], + nested: { + score: (i * 0.017) % 1, + flags: { a: i % 2 === 0, b: i % 3 === 0, c: i % 5 === 0 }, + }, + }, + content: makeText(i, PER_MESSAGE_CHARS), + })), + tools: Array.from({ length: 20 }, (_, i) => ({ + name: `tool_${i}`, + description: makeText(10000 + i, 400), + parameters: { + type: "object", + properties: Object.fromEntries( + Array.from({ length: 8 }, (_, j) => [ + `param_${j}`, + { + type: j % 2 === 0 ? "string" : "number", + description: `description of param ${j} for tool ${i}`, + }, + ]), + ), + }, + })), + model: "gpt-4o", + temperature: 0.7, + }; + + const structuralOutputs = { + choices: [ + { + message: { + role: "assistant", + content: makeText(999999, 8_000), + tool_calls: Array.from({ length: 10 }, (_, i) => ({ + id: `call_${i}`, + type: "function", + function: { + name: `tool_${i}`, + arguments: JSON.stringify({ + query: makeText(20000 + i, 400), + filters: { k1: `v${i}`, k2: i, k3: i % 2 === 0 }, + }), + }, + })), + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 120000, completion_tokens: 48000 }, + }; + + await runBench({ + name: "structural", + inputs: structuralInputs, + outputs: structuralOutputs, + numRuns: 100, + outFile: "bench-structural.json", + }); + }, + 120_000, +); diff --git a/src/lib/tests/profile-lock.test.ts b/src/lib/tests/profile-lock.test.ts new file mode 100644 index 0000000..30c999e --- /dev/null +++ b/src/lib/tests/profile-lock.test.ts @@ -0,0 +1,136 @@ +/* eslint-disable no-process-env */ +import { jest } from "@jest/globals"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { acquireOAuthRefreshLock, _internal } from "../utils/profile-lock.js"; +import { loadProfileClientConfig } from "../utils/profiles.js"; + +function newConfigPath(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ls-lock-")); + return path.join(dir, "config.json"); +} + +function writeMeta(lockDir: string, iso: string, owner: string): void { + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync( + path.join(lockDir, _internal.LOCK_METADATA_FILE), + `${iso}\n${owner}\n`, + ); +} + +test("acquire creates the lock dir and release removes it", async () => { + const configPath = newConfigPath(); + const lockDir = `${configPath}.oauth.lock.lock`; + const lock = await acquireOAuthRefreshLock(configPath, Date.now() + 5000); + expect(fs.existsSync(lockDir)).toBe(true); + await lock.release(); + expect(fs.existsSync(lockDir)).toBe(false); +}); + +test("acquire times out when the lock is held", async () => { + const configPath = newConfigPath(); + const held = await acquireOAuthRefreshLock(configPath, Date.now() + 5000); + await expect( + acquireOAuthRefreshLock(configPath, Date.now() + 50), + ).rejects.toThrow(/timed out/); + await held.release(); +}); + +test("a stale lock (expired timestamp) is broken and reacquired", async () => { + const configPath = newConfigPath(); + const lockDir = `${configPath}.oauth.lock.lock`; + const stale = new Date( + Date.now() - _internal.LOCK_STALE_AFTER_MS - 1000, + ).toISOString(); + writeMeta(lockDir, stale, "someone-else"); + const lock = await acquireOAuthRefreshLock(configPath, Date.now() + 5000); + expect(_internal.lockOwner(lockDir)).not.toBe("someone-else"); + await lock.release(); +}); + +test("release does not remove a lock owned by someone else", async () => { + const configPath = newConfigPath(); + const lockDir = `${configPath}.oauth.lock.lock`; + const lock = await acquireOAuthRefreshLock(configPath, Date.now() + 5000); + // Simulate another owner reclaiming the dir. + writeMeta(lockDir, new Date().toISOString(), "new-owner"); + await lock.release(); + expect(fs.existsSync(lockDir)).toBe(true); + fs.rmSync(lockDir, { recursive: true, force: true }); +}); + +test("two ProfileAuth instances refresh the token endpoint only once", async () => { + const configPath = newConfigPath(); + fs.writeFileSync( + configPath, + JSON.stringify({ + current_profile: "default", + profiles: { + default: { + api_url: "https://api.smith.langchain.com", + oauth: { + access_token: "stale", + refresh_token: "refresh-abc", + expires_at: "2000-01-01T00:00:00Z", + }, + }, + }, + }), + ); + const prev = process.env.LANGSMITH_CONFIG_FILE; + process.env.LANGSMITH_CONFIG_FILE = configPath; + try { + let calls = 0; + const fakeFetch = jest.fn(async (url: unknown) => { + // The deployment serves no discovery document, so refresh falls back to + // the configured mount point. Only the token requests are counted. + if (String(url).includes("/.well-known/oauth-authorization-server")) { + return { + ok: false, + status: 404, + json: async () => ({}), + } as unknown as Response; + } + calls += 1; + return { + ok: true, + json: async () => ({ + access_token: "network-token", + refresh_token: "refresh-xyz", + expires_in: 3600, + }), + } as unknown as Response; + }); + + const authA = loadProfileClientConfig().profileAuth; + const authB = loadProfileClientConfig().profileAuth; + if (!authA || !authB) { + throw new Error("expected profileAuth to be defined"); + } + const headerA = await authA.getAuthHeader( + fakeFetch as unknown as typeof fetch, + ); + const headerB = await authB.getAuthHeader( + fakeFetch as unknown as typeof fetch, + ); + + expect(calls).toBe(1); + expect(headerA).toEqual({ + name: "Authorization", + value: "Bearer network-token", + }); + expect(headerB).toEqual({ + name: "Authorization", + value: "Bearer network-token", + }); + const onDisk = JSON.parse(fs.readFileSync(configPath, "utf-8")); + expect(onDisk.profiles.default.oauth.access_token).toBe("network-token"); + } finally { + if (prev === undefined) { + delete process.env.LANGSMITH_CONFIG_FILE; + } else { + process.env.LANGSMITH_CONFIG_FILE = prev; + } + } +}); diff --git a/src/lib/tests/profile-oauth-discovery.test.ts b/src/lib/tests/profile-oauth-discovery.test.ts new file mode 100644 index 0000000..bbf7dd8 --- /dev/null +++ b/src/lib/tests/profile-oauth-discovery.test.ts @@ -0,0 +1,180 @@ +import * as http from "node:http"; +import { resolveTokenEndpoint } from "../utils/profiles.js"; + +type Doc = Record | string | null; + +/** Start a server whose handler maps (path, base) to JSON, HTML, or a 404. */ +async function serve( + handler: (path: string, base: string) => Doc, +): Promise<{ base: string; close: () => Promise }> { + let base = ""; + const server = http.createServer((req, res) => { + const result = handler(req.url ?? "", base); + if (result === null) { + res.writeHead(404); + res.end(); + return; + } + if (typeof result === "string") { + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(result); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(result)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("failed to bind test server"); + } + base = `http://127.0.0.1:${address.port}`; + return { + base, + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + }), + }; +} + +/** Metadata under /api plus an HTML 200 at the root, like the self-hosted SPA. */ +function selfHostedHandler(path: string, base: string): Doc { + if (path === "/api/.well-known/oauth-authorization-server") { + return { + issuer: `${base}/api`, + device_authorization_endpoint: `${base}/api/oauth/device/code`, + token_endpoint: `${base}/api/oauth/token`, + }; + } + if (path === "/.well-known/oauth-authorization-server") { + return "app"; + } + return null; +} + +describe("resolveTokenEndpoint", () => { + it("resolves the self-hosted AS from a bare origin", async () => { + const { base, close } = await serve(selfHostedHandler); + try { + expect(await resolveTokenEndpoint(base, fetch)).toBe( + `${base}/api/oauth/token`, + ); + } finally { + await close(); + } + }); + + it("resolves the self-hosted AS from an /api url", async () => { + const { base, close } = await serve(selfHostedHandler); + try { + expect(await resolveTokenEndpoint(`${base}/api`, fetch)).toBe( + `${base}/api/oauth/token`, + ); + } finally { + await close(); + } + }); + + it("resolves SaaS at the root", async () => { + const { base, close } = await serve((path, b) => + path === "/.well-known/oauth-authorization-server" + ? { + issuer: b, + device_authorization_endpoint: `${b}/oauth/device/code`, + token_endpoint: `${b}/oauth/token`, + } + : null, + ); + try { + expect(await resolveTokenEndpoint(base, fetch)).toBe( + `${base}/oauth/token`, + ); + } finally { + await close(); + } + }); + + // RFC 8414 inserts the well-known segment before the issuer path; a + // deployment serving only that form must still be discovered. + it("resolves the RFC 8414 path-inserted form", async () => { + const { base, close } = await serve((path, b) => + path === "/.well-known/oauth-authorization-server/api" + ? { + issuer: `${b}/api`, + device_authorization_endpoint: `${b}/api/oauth/device/code`, + token_endpoint: `${b}/api/oauth/token`, + } + : null, + ); + try { + expect(await resolveTokenEndpoint(base, fetch)).toBe( + `${base}/api/oauth/token`, + ); + } finally { + await close(); + } + }); + + it("keeps the /api mount when no metadata is served", async () => { + const { base, close } = await serve(() => null); + try { + expect(await resolveTokenEndpoint(`${base}/api`, fetch)).toBe( + `${base}/api/oauth/token`, + ); + } finally { + await close(); + } + }); + + it("strips /api/v1 in the fallback", async () => { + const { base, close } = await serve(() => null); + try { + expect(await resolveTokenEndpoint(`${base}/api/v1`, fetch)).toBe( + `${base}/oauth/token`, + ); + } finally { + await close(); + } + }); + + // Refresh tokens are posted to this endpoint, so untrusted documents must be + // ignored in favour of the fallback. + it("ignores a document whose issuer does not match", async () => { + const { base, close } = await serve((path, b) => + path === "/.well-known/oauth-authorization-server" + ? { + issuer: "https://evil.example.com", + device_authorization_endpoint: `${b}/oauth/device/code`, + token_endpoint: `${b}/oauth/token`, + } + : null, + ); + try { + expect(await resolveTokenEndpoint(base, fetch)).toBe( + `${base}/oauth/token`, + ); + } finally { + await close(); + } + }); + + it("ignores an endpoint on another origin", async () => { + const { base, close } = await serve((path, b) => + path === "/.well-known/oauth-authorization-server" + ? { + issuer: b, + device_authorization_endpoint: `${b}/oauth/device/code`, + token_endpoint: "https://evil.example.com/oauth/token", + } + : null, + ); + try { + expect(await resolveTokenEndpoint(base, fetch)).toBe( + `${base}/oauth/token`, + ); + } finally { + await close(); + } + }); +}); diff --git a/src/lib/tests/prompt_cache.test.ts b/src/lib/tests/prompt_cache.test.ts new file mode 100644 index 0000000..7385843 --- /dev/null +++ b/src/lib/tests/prompt_cache.test.ts @@ -0,0 +1,837 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { jest } from "@jest/globals"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + PromptCache, + promptCacheSingleton, + configureGlobalPromptCache, +} from "../utils/prompt_cache/index.js"; +import { Client } from "../client.js"; +import type { PromptCommit } from "../schemas.js"; + +// Helper to create a mock PromptCommit +function createMockPromptCommit(name: string): PromptCommit { + return { + owner: "test-owner", + repo: name, + commit_hash: "abc123", + manifest: { template: `Hello from ${name}` }, + examples: [], + }; +} + +describe("Cache", () => { + describe("basic operations", () => { + test("should return undefined for missing keys", () => { + const cache = new PromptCache(); + expect( + cache.get("missing-key", async () => createMockPromptCommit("missing")), + ).toBeUndefined(); + cache.stop(); + }); + + test("should get and set values", () => { + const cache = new PromptCache({ ttlSeconds: null }); + const prompt = createMockPromptCommit("test"); + cache.set("test-key", prompt, async () => prompt); + expect(cache.get("test-key", async () => prompt)).toEqual(prompt); + cache.stop(); + }); + + test("should invalidate entries", () => { + const cache = new PromptCache({ ttlSeconds: null }); + const prompt = createMockPromptCommit("test"); + cache.set("test-key", prompt, async () => prompt); + cache.invalidate("test-key"); + expect(cache.get("test-key", async () => prompt)).toBeUndefined(); + cache.stop(); + }); + + test("should clear all entries", () => { + const cache = new PromptCache({ ttlSeconds: null }); + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache.set("key2", createMockPromptCommit("test2"), async () => + createMockPromptCommit("test2"), + ); + expect(cache.size).toBe(2); + cache.clear(); + expect(cache.size).toBe(0); + cache.stop(); + }); + }); + + describe("LRU eviction", () => { + test("should evict oldest entry when max size reached", () => { + const cache = new PromptCache({ + maxSize: 2, + ttlSeconds: null, + }); + + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache.set("key2", createMockPromptCommit("test2"), async () => + createMockPromptCommit("test2"), + ); + cache.set("key3", createMockPromptCommit("test3"), async () => + createMockPromptCommit("test3"), + ); + + expect(cache.size).toBe(2); + expect( + cache.get("key1", async () => createMockPromptCommit("test1")), + ).toBeUndefined(); // Evicted + expect( + cache.get("key2", async () => createMockPromptCommit("test2")), + ).toBeDefined(); + expect( + cache.get("key3", async () => createMockPromptCommit("test3")), + ).toBeDefined(); + cache.stop(); + }); + + test("should update LRU order on access", () => { + const cache = new PromptCache({ + maxSize: 2, + ttlSeconds: null, + }); + + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache.set("key2", createMockPromptCommit("test2"), async () => + createMockPromptCommit("test2"), + ); + + // Access key1 to make it recently used + cache.get("key1", async () => createMockPromptCommit("test1")); + + // Add key3 - should evict key2 (least recently used) + cache.set("key3", createMockPromptCommit("test3"), async () => + createMockPromptCommit("test3"), + ); + + expect( + cache.get("key1", async () => createMockPromptCommit("test1")), + ).toBeDefined(); + expect( + cache.get("key2", async () => createMockPromptCommit("test2")), + ).toBeUndefined(); // Evicted + expect( + cache.get("key3", async () => createMockPromptCommit("test3")), + ).toBeDefined(); + cache.stop(); + }); + }); + + describe("metrics", () => { + test("should track hits and misses", () => { + const cache = new PromptCache({ ttlSeconds: null }); + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + + cache.get("key1", async () => createMockPromptCommit("test1")); // Hit + cache.get("key1", async () => createMockPromptCommit("test1")); // Hit + cache.get("missing", async () => createMockPromptCommit("missing")); // Miss + + expect(cache.metrics.hits).toBe(2); + expect(cache.metrics.misses).toBe(1); + expect(cache.totalRequests).toBe(3); + expect(cache.hitRate).toBeCloseTo(0.667, 2); + cache.stop(); + }); + + test("should reset metrics", () => { + const cache = new PromptCache({ ttlSeconds: null }); + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache.get("key1", async () => createMockPromptCommit("test1")); + cache.get("missing", async () => createMockPromptCommit("missing")); + + cache.resetMetrics(); + + expect(cache.metrics.hits).toBe(0); + expect(cache.metrics.misses).toBe(0); + expect(cache.metrics.refreshes).toBe(0); + expect(cache.metrics.refreshErrors).toBe(0); + cache.stop(); + }); + }); + + describe("persistence", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "prompt-cache-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("should dump and load cache", () => { + const cachePath = path.join(tempDir, "cache.json"); + const cache1 = new PromptCache({ ttlSeconds: null }); + + cache1.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache1.set("key2", createMockPromptCommit("test2"), async () => + createMockPromptCommit("test2"), + ); + cache1.dump(cachePath); + cache1.stop(); + + const cache2 = new PromptCache({ ttlSeconds: null }); + const loaded = cache2.load(cachePath); + + expect(loaded).toBe(2); + expect( + cache2!.get("key1", async () => createMockPromptCommit("test1")), + ).toBeDefined(); + expect( + cache2!.get("key2", async () => createMockPromptCommit("test2")), + ).toBeDefined(); + cache2.stop(); + }); + + test("should return 0 for non-existent file", () => { + const cache = new PromptCache({ ttlSeconds: null }); + const loaded = cache.load("/non/existent/path.json"); + expect(loaded).toBe(0); + cache.stop(); + }); + + test("should return 0 for corrupted file", () => { + const cachePath = path.join(tempDir, "corrupted.json"); + fs.writeFileSync(cachePath, "not valid json{{{"); + + const cache = new PromptCache({ ttlSeconds: null }); + const loaded = cache.load(cachePath); + expect(loaded).toBe(0); + cache.stop(); + }); + + test("should respect max size when loading", () => { + const cachePath = path.join(tempDir, "cache.json"); + const cache1 = new PromptCache({ + maxSize: 10, + ttlSeconds: null, + }); + + for (let i = 0; i < 5; i++) { + cache1.set(`key${i}`, createMockPromptCommit(`test${i}`), async () => + createMockPromptCommit(`test${i}`), + ); + } + cache1.dump(cachePath); + cache1.stop(); + + const cache2 = new PromptCache({ + maxSize: 3, + ttlSeconds: null, + }); + const loaded = cache2.load(cachePath); + + expect(loaded).toBe(3); + expect(cache2.size).toBe(3); + cache2.stop(); + }); + + test("should create parent directories", () => { + const cachePath = path.join(tempDir, "nested", "dir", "cache.json"); + const cache = new PromptCache({ ttlSeconds: null }); + + cache.set("key1", createMockPromptCommit("test1"), async () => + createMockPromptCommit("test1"), + ); + cache.dump(cachePath); + + expect(fs.existsSync(cachePath)).toBe(true); + cache.stop(); + }); + }); + + describe("background refresh", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + test("should not start refresh when ttlSeconds is null", () => { + const fetchFunc = jest.fn(); + const cache = new PromptCache({ + ttlSeconds: null, // Infinite TTL + }); + + cache.set( + "key1", + createMockPromptCommit("test1"), + fetchFunc as () => Promise, + ); + + // Advance time significantly + jest.advanceTimersByTime(120_000); + + expect(fetchFunc).not.toHaveBeenCalled(); + cache.stop(); + }); + + test("should refresh stale entries", async () => { + const refreshedPrompt = createMockPromptCommit("refreshed"); + const fetchFunc = jest + .fn<() => Promise>() + .mockResolvedValue(refreshedPrompt); + + const cache = new PromptCache({ + ttlSeconds: 1, // 1 second TTL + refreshIntervalSeconds: 1, // Check every second + }); + + cache.set("key1", createMockPromptCommit("original"), fetchFunc); + + // Advance past TTL and refresh interval + jest.advanceTimersByTime(2000); + + // Allow the async refresh to complete + await Promise.resolve(); + + expect(fetchFunc).toHaveBeenCalledWith(); + cache.stop(); + }); + + test("should track refresh errors", async () => { + const fetchFunc = jest + .fn<() => Promise>() + .mockRejectedValue(new Error("Network error")); + + const cache = new PromptCache({ + ttlSeconds: 1, + refreshIntervalSeconds: 1, + }); + + cache.set("key1", createMockPromptCommit("test1"), fetchFunc); + + // Advance past TTL and refresh interval + jest.advanceTimersByTime(2000); + + // Allow the async refresh to complete + await Promise.resolve(); + + expect(cache.metrics.refreshErrors).toBeGreaterThan(0); + cache.stop(); + }); + + test("should keep serving stale data on 500 errors", async () => { + const originalPrompt = createMockPromptCommit("original"); + const error = new Error("500 Internal Server Error"); + const fetchFunc = jest + .fn<() => Promise>() + .mockRejectedValue(error); + + const cache = new PromptCache({ + ttlSeconds: 1, + refreshIntervalSeconds: 1, + }); + + // Set initial value + cache.set("key1", originalPrompt, fetchFunc); + + // Get it before it's stale + const beforeStale = cache.get("key1", fetchFunc); + expect(beforeStale).toEqual(originalPrompt); + + // Advance past TTL to make it stale + jest.advanceTimersByTime(2000); + await Promise.resolve(); + + // Fetch should have been attempted and failed + expect(fetchFunc).toHaveBeenCalled(); + expect(cache.metrics.refreshErrors).toBeGreaterThan(0); + + // Should still serve the stale data + const afterError = cache.get("key1", fetchFunc); + expect(afterError).toEqual(originalPrompt); + expect(cache.metrics.hits).toBeGreaterThan(0); + + cache.stop(); + }); + + test("should continue refresh loop after 500 errors", async () => { + const originalPrompt = createMockPromptCommit("original"); + const updatedPrompt = createMockPromptCommit("updated"); + + // First call fails with 500, second call succeeds + const fetchFunc = jest + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("500 Internal Server Error")) + .mockResolvedValueOnce(updatedPrompt); + + const cache = new PromptCache({ + ttlSeconds: 1, + refreshIntervalSeconds: 1, + }); + + cache.set("key1", originalPrompt, fetchFunc); + + // First refresh attempt (will fail) + jest.advanceTimersByTime(2000); + await Promise.resolve(); + + expect(fetchFunc).toHaveBeenCalledTimes(1); + expect(cache.metrics.refreshErrors).toBe(1); + + // Should still have original data + expect(cache.get("key1", fetchFunc)).toEqual(originalPrompt); + + // Second refresh attempt (will succeed) + jest.advanceTimersByTime(1000); + await Promise.resolve(); + + expect(fetchFunc).toHaveBeenCalledTimes(2); + expect(cache.metrics.refreshes).toBe(1); + + // Should now have updated data + expect(cache.get("key1", fetchFunc)).toEqual(updatedPrompt); + + cache.stop(); + }); + + test("should stop refresh on stop()", () => { + const fetchFunc = jest + .fn<() => Promise>() + .mockResolvedValue(createMockPromptCommit("test")); + + const cache = new PromptCache({ + ttlSeconds: 1, + refreshIntervalSeconds: 1, + }); + + cache.set("key1", createMockPromptCommit("test1"), fetchFunc); + cache.stop(); + + // Advance time after stopping + jest.advanceTimersByTime(10000); + + // Fetch should not be called since we stopped the cache + expect(fetchFunc).not.toHaveBeenCalled(); + }); + }); + + describe("offline mode", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "prompt-cache-offline-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("should work in full offline mode", () => { + const cachePath = path.join(tempDir, "offline-cache.json"); + + // Step 1: Online - populate and dump cache + const onlineCache = new PromptCache({ + ttlSeconds: 3600, + }); + const prompt1 = createMockPromptCommit("test1"); + const prompt2 = createMockPromptCommit("test2"); + onlineCache.set("prompt1", prompt1, async () => prompt1); + onlineCache.set("prompt2", prompt2, async () => prompt2); + onlineCache.dump(cachePath); + onlineCache.stop(); + + // Step 2: Offline - load from file with infinite TTL + const offlineCache = new PromptCache({ + ttlSeconds: null, // Never expire + }); + const loaded = offlineCache.load(cachePath); + + expect(loaded).toBe(2); + expect(offlineCache.get("prompt1", async () => prompt1)).toBeDefined(); + expect(offlineCache.get("prompt2", async () => prompt2)).toBeDefined(); + + // Verify no refresh happens + expect(offlineCache.metrics.refreshes).toBe(0); + offlineCache.stop(); + }); + }); +}); + +describe("Global Singleton", () => { + afterEach(() => { + // Clean up singleton after each test + promptCacheSingleton.clear(); + promptCacheSingleton.resetMetrics(); + }); + + describe("singleton instance", () => { + test("should have a global singleton", () => { + expect(promptCacheSingleton).toBeDefined(); + expect(promptCacheSingleton).toBeInstanceOf(PromptCache); + }); + + test("should have default configuration", () => { + expect(promptCacheSingleton.size).toBe(0); + // Check private properties through bracket notation + expect((promptCacheSingleton as any).maxSize).toBe(100); + expect((promptCacheSingleton as any).ttlSeconds).toBe(5 * 60); // 5 minutes + }); + }); + + describe("Client integration", () => { + test("Client should use singleton by default", () => { + const client = new Client({ apiKey: "test-key" }); + expect((client as any)._promptCache).toBe(promptCacheSingleton); + }); + + test("Client can disable caching", () => { + const client = new Client({ + apiKey: "test-key", + disablePromptCache: true, + }); + expect((client as any)._promptCache).toBeUndefined(); + }); + + test("Multiple clients share the same cache", () => { + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + + expect((client1 as any)._promptCache).toBe((client2 as any)._promptCache); + expect((client1 as any)._promptCache).toBe(promptCacheSingleton); + }); + + test("Clients share cached values", () => { + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + + const prompt = createMockPromptCommit("shared-prompt"); + promptCacheSingleton.set("shared-key", prompt, async () => prompt); + + // Both clients should see the cached value + expect((client1 as any)._promptCache?.get("shared-key")).toEqual(prompt); + expect((client2 as any)._promptCache?.get("shared-key")).toEqual(prompt); + }); + }); +}); + +describe("cache disabled with maxSize: 0", () => { + test("should not cache when maxSize is 0", () => { + const cache = new PromptCache({ maxSize: 0 }); + const prompt = createMockPromptCommit("test"); + const refreshFunc = async () => prompt; + + // Set should do nothing + cache.set("key1", prompt, refreshFunc); + + // Get should always return undefined + const result = cache.get("key1", refreshFunc); + expect(result).toBeUndefined(); + + // No metrics should be tracked when disabled + expect(cache.metrics.hits).toBe(0); + expect(cache.metrics.misses).toBe(0); + + // Cache should be empty + expect(cache.size).toBe(0); + + cache.stop(); + }); + + test("should not track metrics when cache is disabled", () => { + const cache = new PromptCache({ maxSize: 0 }); + const prompt = createMockPromptCommit("test"); + const refreshFunc = async () => prompt; + + // Multiple operations + cache.set("key1", prompt, refreshFunc); + cache.get("key1", refreshFunc); + cache.get("key2", refreshFunc); + cache.set("key2", prompt, refreshFunc); + cache.get("key1", refreshFunc); + + // No metrics should be tracked + expect(cache.metrics.hits).toBe(0); + expect(cache.metrics.misses).toBe(0); + expect(cache.totalRequests).toBe(0); + + cache.stop(); + }); + + test("should disable cache when reconfigured to maxSize: 0", () => { + const cache = new PromptCache({ maxSize: 10 }); + const prompt = createMockPromptCommit("test"); + const refreshFunc = async () => prompt; + + // Add entries + cache.set("key1", prompt, refreshFunc); + cache.set("key2", prompt, refreshFunc); + + // Verify they exist + expect(cache.get("key1", refreshFunc)).toBeDefined(); + expect(cache.get("key2", refreshFunc)).toBeDefined(); + expect(cache.size).toBe(2); + + // Reconfigure to disable cache + cache.configure({ maxSize: 0 }); + + // Now all gets should return undefined + expect(cache.get("key1", refreshFunc)).toBeUndefined(); + expect(cache.get("key2", refreshFunc)).toBeUndefined(); + + // Sets should do nothing + cache.set("key3", prompt, refreshFunc); + expect(cache.get("key3", refreshFunc)).toBeUndefined(); + expect(cache.size).toBe(2); // Size unchanged (entries still in Map, but not accessible) + + cache.stop(); + }); + + test("should allow disabling cache globally with configureGlobalPromptCache", () => { + const initialMaxSize = (promptCacheSingleton as any).maxSize; + + try { + // Configure global cache to be disabled + configureGlobalPromptCache({ maxSize: 0 }); + + // Create clients + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + + const prompt = createMockPromptCommit("test"); + const refreshFunc = async () => prompt; + + // Set via client1's cache + (client1 as any)._promptCache!.set("key1", prompt, refreshFunc); + + // Get via client2's cache - should return undefined (disabled) + const result = (client2 as any)._promptCache!.get("key1", refreshFunc); + expect(result).toBeUndefined(); + + // Verify it's the same singleton + expect((client1 as any)._promptCache).toBe((client2 as any)._promptCache); + expect((client1 as any)._promptCache).toBe(promptCacheSingleton); + } finally { + // Restore original maxSize + configureGlobalPromptCache({ maxSize: initialMaxSize }); + promptCacheSingleton.clear(); + } + }); + + test("should not start refresh timer when maxSize is 0", () => { + jest.useFakeTimers(); + + try { + const cache = new PromptCache({ + maxSize: 0, + ttlSeconds: 1, + refreshIntervalSeconds: 1, + }); + + const fetchFunc = jest + .fn<() => Promise>() + .mockResolvedValue(createMockPromptCommit("test")); + + // Set with disabled cache + cache.set("key1", createMockPromptCommit("test"), fetchFunc); + + // Advance time + jest.advanceTimersByTime(10000); + + // Refresh should never be called since cache is disabled + expect(fetchFunc).not.toHaveBeenCalled(); + expect((cache as any).refreshTimer).toBeUndefined(); + + cache.stop(); + } finally { + jest.useRealTimers(); + } + }); +}); + +describe("additional singleton tests", () => { + test("should configure singleton after clients created", () => { + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + + // Get initial config + const initialMaxSize = (promptCacheSingleton as any).maxSize; + const initialTtl = (promptCacheSingleton as any).ttlSeconds; + + try { + // Configure singleton + configureGlobalPromptCache({ maxSize: 200, ttlSeconds: 7200 }); + + // Both clients should see new config (same singleton) + expect(((client1 as any)._promptCache as any).maxSize).toBe(200); + expect(((client1 as any)._promptCache as any).ttlSeconds).toBe(7200); + expect(((client2 as any)._promptCache as any).maxSize).toBe(200); + expect(((client2 as any)._promptCache as any).ttlSeconds).toBe(7200); + + // Verify it's the same object + expect((client1 as any)._promptCache).toBe((client2 as any)._promptCache); + expect((client1 as any)._promptCache).toBe(promptCacheSingleton); + } finally { + // Restore + configureGlobalPromptCache({ + maxSize: initialMaxSize, + ttlSeconds: initialTtl, + }); + } + }); + + test("should share metrics across clients", () => { + promptCacheSingleton.clear(); + promptCacheSingleton.resetMetrics(); + + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + + const prompt = createMockPromptCommit("test"); + + // Client 1 sets a value + (client1 as any)._promptCache!.set("key1", prompt); + + // Client 2 gets the value (hit) + const result = (client2 as any)._promptCache!.get("key1"); + expect(result).toBeDefined(); + + // Metrics should be shared + expect((client1 as any)._promptCache!.metrics.hits).toBe(1); + expect((client2 as any)._promptCache!.metrics.hits).toBe(1); // Same metrics object + + // Client 1 misses + (client1 as any)._promptCache!.get("missing-key"); + + // Client 2 should see the miss + expect((client2 as any)._promptCache!.metrics.misses).toBe(1); + }); + + test("should share refresh timer across clients", async () => { + promptCacheSingleton.clear(); + promptCacheSingleton.resetMetrics(); + promptCacheSingleton.stop(); + + const mockFetch = jest + .fn<(key: string) => Promise>() + .mockResolvedValue(createMockPromptCommit("refreshed")); + + // Configure singleton with fetch func + (promptCacheSingleton as any).fetchFunc = mockFetch; + (promptCacheSingleton as any).ttlSeconds = 10; + + try { + const client1 = new Client({ apiKey: "test-key-1" }); + const client2 = new Client({ apiKey: "test-key-2" }); + const client3 = new Client({ apiKey: "test-key-3" }); + + // Trigger timer start via any client + const prompt = createMockPromptCommit("test"); + (client1 as any)._promptCache!.set("key1", prompt); + + // All should share the same timer (hard to check directly, but we can verify behavior) + expect((client1 as any)._promptCache).toBe((client2 as any)._promptCache); + expect((client2 as any)._promptCache).toBe((client3 as any)._promptCache); + expect(((client1 as any)._promptCache as any).refreshTimer).toBeDefined(); + } finally { + (promptCacheSingleton as any).fetchFunc = undefined; + promptCacheSingleton.stop(); + promptCacheSingleton.clear(); + } + }); + + test("should persist and load singleton data", () => { + const tempDir = os.tmpdir(); + const cachePath = path.join(tempDir, "singleton_cache_test.json"); + + promptCacheSingleton.clear(); + promptCacheSingleton.resetMetrics(); + + try { + const client1 = new Client({ apiKey: "test-key-1" }); + + // Client sets some values + (client1 as any)._promptCache!.set( + "prompt1", + createMockPromptCommit("test1"), + ); + (client1 as any)._promptCache!.set( + "prompt2", + createMockPromptCommit("test2"), + ); + + // Dump via singleton + promptCacheSingleton.dump(cachePath); + + // Clear and load + promptCacheSingleton.clear(); + const loaded = promptCacheSingleton.load(cachePath); + + expect(loaded).toBe(2); + + // New client should see loaded values + const client2 = new Client({ apiKey: "test-key-2" }); + expect((client2 as any)._promptCache!.get("prompt1")).toBeDefined(); + expect((client2 as any)._promptCache!.get("prompt2")).toBeDefined(); + } finally { + if (fs.existsSync(cachePath)) { + fs.unlinkSync(cachePath); + } + promptCacheSingleton.clear(); + } + }); + + test("should stop refresh timer when configuring", () => { + promptCacheSingleton.clear(); + promptCacheSingleton.resetMetrics(); + promptCacheSingleton.stop(); + + const mockFetch = jest + .fn<(key: string) => Promise>() + .mockResolvedValue(createMockPromptCommit("refreshed")); + + // Configure with fetch func and start timer + (promptCacheSingleton as any).fetchFunc = mockFetch; + (promptCacheSingleton as any).ttlSeconds = 10; + + const prompt = createMockPromptCommit("test"); + promptCacheSingleton.set("key1", prompt, async () => prompt); + + // Timer should be running + expect((promptCacheSingleton as any).refreshTimer).toBeDefined(); + const firstTimer = (promptCacheSingleton as any).refreshTimer; + + // Reconfigure - should stop the timer + promptCacheSingleton.configure({ maxSize: 200, ttlSeconds: 20 }); + + // Timer should be cleared by configure (configure calls stop()) + expect((promptCacheSingleton as any).refreshTimer).toBeUndefined(); + + // Set again to restart timer with new config + promptCacheSingleton.set("key2", prompt, async () => prompt); + + // New timer should be started + expect((promptCacheSingleton as any).refreshTimer).toBeDefined(); + const secondTimer = (promptCacheSingleton as any).refreshTimer; + + // Should be a different timer object + expect(secondTimer).not.toBe(firstTimer); + + // Clean up + (promptCacheSingleton as any).fetchFunc = undefined; + promptCacheSingleton.stop(); + promptCacheSingleton.clear(); + }); +}); diff --git a/src/lib/tests/public_runs.int.test.ts b/src/lib/tests/public_runs.int.test.ts new file mode 100644 index 0000000..767f9c0 --- /dev/null +++ b/src/lib/tests/public_runs.int.test.ts @@ -0,0 +1,124 @@ +/** + * Smoke integration tests for the v2 public.runs resource. + * + * Covers `Client.public.runs` (retrieve, query) against a live backend. Public + * shared-run reads are authenticated by the share token in the path; the + * client's API key is used only to mint that token via runs.share.create. + */ +import { Client } from "../client.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { + deleteProject, + pollRunsUntilCount, + resolveTraceId, + waitUntil, +} from "./utils.js"; + +// Public run fields to request; START_TIME is needed as the retrieve coordinate. +const SELECTS: Array<"ID" | "NAME" | "RUN_TYPE" | "STATUS" | "START_TIME"> = [ + "ID", + "NAME", + "RUN_TYPE", + "STATUS", + "START_TIME", +]; + +async function setUpSharedRun(client: Client) { + const projectName = `__test_v2_public_runs_${uuidv4().slice(0, 12)}`; + if (await client.hasProject({ projectName })) { + await deleteProject(client, projectName); + } + + const runId = uuidv4(); + const now = new Date(); + await client.createRun({ + id: runId, + name: "run_1", + inputs: { i: 1 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime(), + }); + await pollRunsUntilCount(client, projectName, 1, 30_000); + + const project = await client.readProject({ projectName }); + const traceId = await resolveTraceId(client, runId, project.id); + + let shareToken: string | undefined; + // share.create resolves the trace root from SmithDB, which may lag indexing. + await waitUntil( + async () => { + const resp = await client.runs.share.create(runId, { + session_id: project.id, + trace_id: traceId, + }); + shareToken = resp.share_token; + return !!shareToken; + }, + 90_000, + 3_000, + ); + if (!shareToken) throw new Error("share.create did not return a share_token"); + + return { projectName, runId, projectId: project.id, shareToken }; +} + +test("public.runs.query returns the shared trace's runs by share token", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, runId, shareToken } = await setUpSharedRun(client); + try { + let found = false; + await waitUntil( + async () => { + const page = await client.public.runs.query(shareToken, { + selects: SELECTS, + }); + found = (page.items ?? []).some((i) => i.id === runId); + return found; + }, + 90_000, + 3_000, + ); + expect(found).toBe(true); + } finally { + await deleteProject(client, projectName); + } +}); + +test("public.runs.retrieve returns a single run by share token, id, start_time", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, runId, shareToken } = await setUpSharedRun(client); + try { + let retrievedId: string | undefined; + await waitUntil( + async () => { + // Derive the exact start_time coordinate from the public read path + // itself, so it matches what the backend stored (retrieve is a point + // lookup). + const page = await client.public.runs.query(shareToken, { + selects: SELECTS, + }); + const item = (page.items ?? []).find((i) => i.id === runId); + if (!item?.start_time) return false; + const run = await client.public.runs.retrieve(runId, { + share_token: shareToken, + selects: SELECTS, + start_time: item.start_time, + }); + retrievedId = run.id; + return retrievedId === runId; + }, + 90_000, + 3_000, + ); + expect(retrievedId).toBe(runId); + } finally { + await deleteProject(client, projectName); + } +}); diff --git a/src/lib/tests/replica_endpoints.test.ts b/src/lib/tests/replica_endpoints.test.ts new file mode 100644 index 0000000..f201acf --- /dev/null +++ b/src/lib/tests/replica_endpoints.test.ts @@ -0,0 +1,923 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +/* eslint-disable prefer-const */ +import { jest, describe } from "@jest/globals"; +import { Client } from "../client.js"; +import { RunTree } from "../run_trees.js"; +import { traceable } from "../traceable.js"; +import { getLangSmithEnvironmentVariable } from "../utils/env.js"; +import { mockClient } from "./utils/mock_client.js"; + +// Helper function to parse mock request body +const parseMockRequestBody = async ( + body: string | Uint8Array | ReadableStream | undefined, +): Promise<{ post: any[]; patch: any[] }> => { + if (!body) { + return { post: [], patch: [] }; + } + + if (typeof body === "string") { + return JSON.parse(body); + } + + let rawMultipart: string; + if (body instanceof ReadableStream) { + rawMultipart = await new Response(body).text(); + } else { + rawMultipart = new TextDecoder().decode(body); + } + + if (rawMultipart.trim().startsWith("{")) { + return JSON.parse(rawMultipart); + } + + // Parse the multipart form data boundary from the raw text + const boundary = rawMultipart.split("\r\n")[0].trim(); + // Split the multipart body into individual parts + const parts = rawMultipart.split(boundary).slice(1, -1); + + const entries: [string, any][] = parts.map((part) => { + const [headers, ...contentParts] = part.trim().split("\r\n\r\n"); + const content = contentParts.join("\r\n\r\n"); + // Extract the name from Content-Disposition header + const nameMatch = headers.match(/name="([^"]+)"/); + const name = nameMatch ? nameMatch[1] : ""; + return [name, content.trim()]; + }); + + const reconstructedBody: any = { + post: [], + patch: [], + }; + + for (const [key, value] of entries) { + let [method, id, type] = key.split("."); + let parsedValue; + try { + parsedValue = JSON.parse(value); + } catch (_e) { + parsedValue = value; + } + + if (!(method in reconstructedBody)) { + throw new Error(`${method} must be "post" or "patch"`); + } + + if (!type) { + reconstructedBody[method as keyof typeof reconstructedBody].push( + parsedValue, + ); + } else { + for (const item of reconstructedBody[method]) { + if (item.id === id) { + item[type] = parsedValue; + } + } + } + } + + return reconstructedBody; +}; + +describe("LANGSMITH_RUNS_ENDPOINTS Replica Testing", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + // Save original environment + originalEnv = { ...process.env }; + + // Clear relevant environment variables + delete process.env.LANGSMITH_ENDPOINT; + delete process.env.LANGCHAIN_ENDPOINT; + delete process.env.LANGSMITH_RUNS_ENDPOINTS; + delete process.env.LANGSMITH_API_KEY; + delete process.env.LANGCHAIN_API_KEY; + }); + + afterEach(() => { + // Restore original environment + process.env = originalEnv; + jest.clearAllMocks(); + }); + + describe("Environment Variable Reading", () => { + it("should parse LANGSMITH_RUNS_ENDPOINTS JSON correctly", () => { + const endpointsConfig = { + "https://api.smith.langchain.com": "primary-key-123", + "https://replica1.example.com": "replica1-key-456", + "https://replica2.example.com": "replica2-key-789", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const envVar = getLangSmithEnvironmentVariable("RUNS_ENDPOINTS"); + expect(envVar).toBe(JSON.stringify(endpointsConfig)); + + const parsed = JSON.parse(envVar!); + expect(parsed).toEqual(endpointsConfig); + }); + + it("should handle malformed JSON gracefully", () => { + process.env.LANGSMITH_RUNS_ENDPOINTS = "invalid-json"; + + const envVar = getLangSmithEnvironmentVariable("RUNS_ENDPOINTS"); + expect(envVar).toBe("invalid-json"); + + expect(() => JSON.parse(envVar!)).toThrow(); + }); + + it("should prioritize LANGSMITH_RUNS_ENDPOINTS over LANGCHAIN_ variant", () => { + process.env.LANGCHAIN_RUNS_ENDPOINTS = JSON.stringify({ + "https://old.example.com": "old-key", + }); + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify({ + "https://new.example.com": "new-key", + }); + + const envVar = getLangSmithEnvironmentVariable("RUNS_ENDPOINTS"); + expect(JSON.parse(envVar!)).toEqual({ + "https://new.example.com": "new-key", + }); + }); + + it("should parse LANGSMITH_RUNS_ENDPOINTS with new array format", () => { + const endpointsConfig = [ + { api_url: "https://api.smith.langchain.com", api_key: "key1" }, + { api_url: "https://api.smith.langchain.com", api_key: "key2" }, + { api_url: "https://replica.example.com", api_key: "replica-key" }, + ]; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const envVar = getLangSmithEnvironmentVariable("RUNS_ENDPOINTS"); + expect(envVar).toBe(JSON.stringify(endpointsConfig)); + + const parsed = JSON.parse(envVar!); + expect(parsed).toEqual(endpointsConfig); + }); + + it("should parse LANGSMITH_RUNS_ENDPOINTS with object format", () => { + const endpointsConfig = { + "https://api.smith.langchain.com": "key1", + "https://replica.example.com": "single-key", + "https://another.example.com": "key3", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const envVar = getLangSmithEnvironmentVariable("RUNS_ENDPOINTS"); + expect(envVar).toBe(JSON.stringify(endpointsConfig)); + + const parsed = JSON.parse(envVar!); + expect(parsed).toEqual(endpointsConfig); + }); + }); + + describe("Client Replica Configuration", () => { + it("should configure client with replica endpoints from environment", async () => { + const endpointsConfig = { + "https://api.smith.langchain.com": "primary-key", + "https://replica1.example.com": "replica1-key", + "https://replica2.example.com": "replica2-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + // Mock the main caller for createRun + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + // Create a run that should be sent to all replicas + const runCreate = { + id: "test-run-id", + name: "test-run", + inputs: { input: "test" }, + run_type: "chain" as const, + start_time: Date.now(), + }; + + await client.createRun(runCreate); + + // Verify calls were made (the exact number depends on internal batching logic) + expect(callSpy).toHaveBeenCalled(); + }); + + it("should handle different API keys for different replicas", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-secret-key", + "https://backup.example.com": "backup-secret-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const runCreate = { + id: "test-run-id", + name: "test-run", + inputs: { input: "test" }, + run_type: "chain" as const, + start_time: Date.now(), + }; + + await client.createRun(runCreate); + + expect(callSpy).toHaveBeenCalled(); + }); + }); + + describe("RunTree Replicas", () => { + it("should reject multiple primary replicas", () => { + expect( + () => + new RunTree({ + name: "test-run", + inputs: {}, + replicas: [ + { projectName: "primary-1", primary: true }, + { projectName: "primary-2", primary: true }, + ], + }), + ).toThrow("Only one replica"); + }); + + it("should send traces to multiple replicas via RunTree", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://secondary.example.com": "secondary-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + // Mock HTTP calls + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + }); + + await runTree.postRun(); + + // Verify calls were made for primary endpoint + expect(callSpy).toHaveBeenCalled(); + }); + + it("should handle replica-specific project names", async () => { + const client = new Client({ autoBatchTracing: false }); + + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "primary-project", + replicas: [ + { + apiUrl: "https://replica1.example.com", + apiKey: "replica1-key", + projectName: "replica1-project", + }, + { + apiUrl: "https://replica2.example.com", + apiKey: "replica2-key", + projectName: "replica2-project", + }, + ], + }); + + await runTree.postRun(); + + // Should make calls for primary + replicas (at least 1 call) + expect(callSpy).toHaveBeenCalled(); + }); + + it("should handle new array format with multiple endpoints", async () => { + const endpointsConfig = [ + { + api_url: "https://workspace1.example.com", + api_key: "workspace1-key", + }, + { + api_url: "https://workspace2.example.com", + api_key: "workspace2-key", + }, + { + api_url: "https://workspace1.example.com", + api_key: "workspace1-alt-key", + }, + ]; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + }); + + await runTree.postRun(); + + // Should make calls for all replicas + expect(callSpy).toHaveBeenCalled(); + }); + + it("should preserve project names from array format", async () => { + const endpointsConfig = [ + { + api_url: "https://workspace1.example.com", + api_key: "workspace1-key", + project_name: "project-prod", + primary: true, + }, + { + api_url: "https://workspace2.example.com", + api_key: "workspace2-key", + project_name: "project-staging", + primary: false, + }, + { + api_url: "https://invalid.example.com", + api_key: "invalid-key", + project_name: "invalid-project", + primary: "true", + }, + ]; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + const createRunSpy = jest + .spyOn(client, "createRun") + .mockResolvedValue(undefined); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "fallback-project", + }); + + expect(runTree.replicas?.map((replica) => replica.primary)).toEqual([ + true, + false, + ]); + + await runTree.postRun(); + + const sessionNames = createRunSpy.mock.calls.map( + ([runCreate]: any) => runCreate.session_name, + ); + + expect(sessionNames.sort()).toEqual(["project-prod", "project-staging"]); + }); + + it("should handle object format", async () => { + const endpointsConfig = { + "https://workspace1.example.com": "workspace1-key", + "https://workspace2.example.com": "workspace2-key", + "https://single.example.com": "single-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + }); + + await runTree.postRun(); + + // Should make calls for all replicas (3 total) + expect(callSpy).toHaveBeenCalled(); + }); + }); + + describe("Traceable with Replicas", () => { + it("should send traces to replicas when using traceable functions", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://replica.example.com": "replica-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + const tracedFunction = traceable( + async (input: string) => { + return { output: `processed: ${input}` }; + }, + { + client, + name: "test-traceable", + tracingEnabled: true, + }, + ); + + const result = await tracedFunction("test input"); + + expect(result).toEqual({ output: "processed: test input" }); + + // Verify traces were sent + expect(callSpy).toHaveBeenCalled(); + }); + }); + + describe("Batch Processing with Replicas", () => { + it("should send batched runs to multiple endpoints", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://replica.example.com": "replica-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve(""), + json: () => Promise.resolve({}), + } as Response); + + const client = new Client({ + autoBatchTracing: true, + fetchImplementation: mockFetch, + }); + + const batchCallSpy = mockFetch; + + jest.spyOn(client as any, "_getServerInfo").mockResolvedValue({ + version: "test", + batch_ingest_config: { use_multipart_endpoint: false }, + }); + + // Create multiple runs using traceable to avoid authorization issues + const traceableFn = traceable( + async (input: string) => ({ output: input }), + { client, name: "batch-test", tracingEnabled: true }, + ); + + // Execute multiple traceable functions + const promises = Array.from({ length: 3 }, (_, i) => + traceableFn(`test-${i}`), + ); + + await Promise.all(promises); + await client.awaitPendingTraceBatches(); + + // Should have exactly two requests (one per replica endpoint) + expect(batchCallSpy).toHaveBeenCalledTimes(2); + + // Parse the batch payloads to verify separate batches per replica + const calls = batchCallSpy.mock.calls; + const parsedBodies = await Promise.all( + calls.map(async (call) => { + const requestParam = call[1] as any; // fetch calls have [url, init] format + return parseMockRequestBody(requestParam?.body); + }), + ); + + // Should have exactly 2 batches (one per replica) + expect(parsedBodies).toHaveLength(2); + + // Both batches should have identical structure and content + parsedBodies.forEach((body) => { + expect(body.post).toHaveLength(3); // 3 traceable function calls + expect(body.patch).toHaveLength(0); // Updates should be combined into post + + // Verify each posted run has the expected structure + body.post.forEach((run: any, index: number) => { + expect(run).toMatchObject({ + name: "batch-test", + inputs: { input: `test-${index}` }, + outputs: { output: `test-${index}` }, + run_type: "chain", + }); + }); + }); + + // Verify both batches contain identical runs + expect(parsedBodies[0].post).toEqual(parsedBodies[1].post); + expect(parsedBodies[0].patch).toEqual(parsedBodies[1].patch); + }); + + it("should handle multipart batch endpoint with replicas", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://replica.example.com": "replica-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const multipartCallSpy = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + + const client = new Client({ + autoBatchTracing: true, + fetchImplementation: multipartCallSpy, + }); + + jest.spyOn(client as any, "_getServerInfo").mockResolvedValue({ + version: "test", + batch_ingest_config: { use_multipart_endpoint: true }, + }); + + const traceableFn = traceable( + async (input: string) => ({ output: input }), + { client, name: "multipart-test", tracingEnabled: true }, + ); + + // Execute multiple traceable functions to ensure batching + const promises = Array.from({ length: 2 }, (_, i) => + traceableFn(`multipart-test-${i}`), + ); + + await Promise.all(promises); + await client.awaitPendingTraceBatches(); + + // Should have exactly two requests (one per replica endpoint) + expect(multipartCallSpy).toHaveBeenCalledTimes(2); + + // Parse the multipart batch payloads to verify separate batches per replica + const calls = multipartCallSpy.mock.calls; + const parsedBodies = await Promise.all( + calls.map(async (call) => { + const requestParam = call[1] as any; + return parseMockRequestBody(requestParam?.body); + }), + ); + + // Should have exactly 2 batches (one per replica) + expect(parsedBodies).toHaveLength(2); + + // Both batches should have identical structure and content + parsedBodies.forEach((body) => { + expect(body.post).toHaveLength(2); // 2 traceable function calls + expect(body.patch).toHaveLength(0); // Updates should be combined into post + + // Verify each posted run has the expected structure + body.post.forEach((run: any, index: number) => { + expect(run).toMatchObject({ + name: "multipart-test", + inputs: { input: `multipart-test-${index}` }, + outputs: { output: `multipart-test-${index}` }, + run_type: "chain", + }); + }); + }); + + // Verify both batches contain identical runs + expect(parsedBodies[0].post).toEqual(parsedBodies[1].post); + expect(parsedBodies[0].patch).toEqual(parsedBodies[1].patch); + }); + }); + + describe("Error Handling", () => { + it("should handle replica endpoint failures gracefully", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://failing-replica.example.com": "replica-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: false }); + + // Mock primary to succeed, replica to fail + const callSpy = jest + .spyOn((client as any).caller, "call") + .mockImplementation((...args: unknown[]) => { + const url = args[0] as string; + if (url.includes("failing-replica")) { + return Promise.reject(new Error("Network error")); + } + return Promise.resolve({ ok: true, text: () => "" }); + }); + + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + replicas: [ + { + apiUrl: "https://failing-replica.example.com", + apiKey: "replica-key", + projectName: "replica-project", + }, + ], + }); + + // Should not throw, but should log errors + await expect(runTree.postRun()).resolves.not.toThrow(); + + expect(callSpy).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + + it("should validate endpoint URLs", () => { + const invalidEndpointsConfig = { + "not-a-valid-url": "some-key", + "https://valid.example.com": "valid-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify( + invalidEndpointsConfig, + ); + + // Creating client should not throw, but invalid URLs might be handled during requests + expect(() => new Client({ autoBatchTracing: false })).not.toThrow(); + }); + + it("should handle invalid value types in LANGSMITH_RUNS_ENDPOINTS", async () => { + const invalidEndpointsConfig = { + "https://valid-string.example.com": "valid-key", + "https://invalid-number.example.com": 123, + "https://invalid-object.example.com": { key: "value" }, + "https://invalid-null.example.com": null, + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify( + invalidEndpointsConfig, + ); + + // Spy on console.warn to check warning messages + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client({ autoBatchTracing: false }); + + // Mock HTTP calls to prevent actual requests + jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + // Create a RunTree to trigger replica parsing + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + }); + + await runTree.postRun(); + + // Should warn about invalid value types + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid value type in LANGSMITH_RUNS_ENDPOINTS", + ), + ); + + warnSpy.mockRestore(); + }); + + it("should handle invalid items in new array format", async () => { + const invalidEndpointsConfig = [ + { api_url: "https://valid.example.com", api_key: "valid-key" }, + "invalid-string-item", + { api_url: "https://missing-key.example.com" }, // missing api_key + { api_key: "missing-url-key" }, // missing api_url + { api_url: 123, api_key: "invalid-url-type" }, // invalid api_url type + { api_url: "https://invalid-key-type.example.com", api_key: 456 }, // invalid api_key type + ]; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify( + invalidEndpointsConfig, + ); + + // Spy on console.warn to check warning messages + const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); + + const client = new Client({ autoBatchTracing: false }); + + jest + .spyOn((client as any).caller, "call") + .mockResolvedValue({ ok: true, text: () => "" }); + + // Create a RunTree to trigger replica parsing + const runTree = new RunTree({ + name: "test-run", + inputs: { input: "test" }, + client, + project_name: "test-project", + }); + + await runTree.postRun(); + + // Should warn about various invalid types + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid item type in LANGSMITH_RUNS_ENDPOINTS", + ), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid api_url type in LANGSMITH_RUNS_ENDPOINTS", + ), + ); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid api_key type in LANGSMITH_RUNS_ENDPOINTS", + ), + ); + + warnSpy.mockRestore(); + }); + }); + + describe("Performance and Concurrency", () => { + it("should handle concurrent requests to multiple replicas", async () => { + const endpointsConfig = { + "https://primary.example.com": "primary-key", + "https://replica1.example.com": "replica1-key", + "https://replica2.example.com": "replica2-key", + "https://replica3.example.com": "replica3-key", + }; + + process.env.LANGSMITH_RUNS_ENDPOINTS = JSON.stringify(endpointsConfig); + + const client = new Client({ autoBatchTracing: true }); + + const callSpy = jest + .spyOn((client as any).batchIngestCaller, "callWithOptions") + .mockResolvedValue({ ok: true, text: () => "" }); + + jest.spyOn(client as any, "_getServerInfo").mockResolvedValue({ + version: "test", + batch_ingest_config: { use_multipart_endpoint: false }, + }); + + // Create many concurrent traceable functions + const traceableFn = traceable( + async (input: string) => ({ output: input }), + { client, name: "concurrent-test", tracingEnabled: true }, + ); + + const concurrentRuns = Array.from({ length: 20 }, (_, i) => + traceableFn(`concurrent-${i}`), + ); + + await Promise.all(concurrentRuns); + await client.awaitPendingTraceBatches(); + + expect(callSpy).toHaveBeenCalled(); + }); + }); + + describe("Replica Behavior with Main Project", () => { + it("replica targeting same project as default should include children", async () => { + const { client, callSpy } = mockClient(); + const mainProject = "main-project"; + + // Grandchild has no replicas + const grandchild = traceable( + async () => { + return "grandchild"; + }, + { + name: "grandchild", + client, + project_name: mainProject, + tracingEnabled: true, + }, + ); + + // Child has replicas: one for main project, one for replica project with reroot + const child = traceable( + async () => { + const grandchildRes = await grandchild(); + return "child: " + grandchildRes; + }, + { + replicas: [ + { + projectName: mainProject, // Same as default + }, + { + projectName: "replica-project", + reroot: true, + }, + ], + name: "child", + client, + project_name: mainProject, + tracingEnabled: true, + }, + ); + + // Parent has no replicas + const parent = traceable( + async () => { + const childRes = await child(); + return "parent: " + childRes; + }, + { + name: "parent", + client, + project_name: mainProject, + tracingEnabled: true, + }, + ); + + await parent(); + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Get all POST calls + const allPostCalls = callSpy.mock.calls.filter( + (call: any) => + (call[0] as string).includes("/runs") && + (call[1] as any)?.method === "POST", + ); + + // Parse runs from calls + const runs = allPostCalls.map((call: any) => { + const body = (call[1] as any)?.body; + let bodyStr: string; + if (typeof body === "string") { + bodyStr = body; + } else if (Buffer.isBuffer(body)) { + bodyStr = body.toString("utf-8"); + } else if (ArrayBuffer.isView(body)) { + bodyStr = new TextDecoder().decode(body); + } else { + bodyStr = JSON.stringify(body); + } + return JSON.parse(bodyStr); + }); + + // Count runs by project + const mainProjectRuns = runs.filter( + (r: any) => r.session_name === mainProject, + ); + const replicaProjectRuns = runs.filter( + (r: any) => r.session_name === "replica-project", + ); + + // Expected: + // - Parent: 1 in main (no replicas, uses default) + // - Child: 2 (main via default + replica-project via replica with new ID, skips main replica) + // - Grandchild: 2 (inherits child's replicas, main via default + replica-project via replica with new ID) + // Total: 5 runs (3 in main, 2 in replica-project) + + expect(runs.length).toBe(5); + expect(mainProjectRuns.length).toBe(3); // parent, child, grandchild + expect(replicaProjectRuns.length).toBe(2); // child (rerooted), grandchild (remapped IDs) + + // Verify each run type appears in main project + const mainNames = mainProjectRuns.map((r: any) => r.name).sort(); + expect(mainNames).toEqual(["child", "grandchild", "parent"]); + + // Verify child and grandchild appear in replica project + const replicaNames = replicaProjectRuns.map((r: any) => r.name).sort(); + expect(replicaNames).toEqual(["child", "grandchild"]); + }); + }); +}); diff --git a/src/lib/tests/run_stats.test.ts b/src/lib/tests/run_stats.test.ts new file mode 100644 index 0000000..7e5a9cb --- /dev/null +++ b/src/lib/tests/run_stats.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@jest/globals"; +import { Client } from "../client.js"; + +describe("getRunStats", () => { + it("throws when neither projectNames nor projectIds is provided", async () => { + const client = new Client({ apiKey: "test", apiUrl: "http://localhost" }); + await expect(client.getRunStats({})).rejects.toThrow( + "projectNames or projectIds", + ); + }); + + it("throws when projectIds is an empty array", async () => { + const client = new Client({ apiKey: "test", apiUrl: "http://localhost" }); + await expect(client.getRunStats({ projectIds: [] })).rejects.toThrow( + "projectNames or projectIds", + ); + }); +}); diff --git a/src/lib/tests/run_trees.int.test.ts b/src/lib/tests/run_trees.int.test.ts new file mode 100644 index 0000000..3461a8e --- /dev/null +++ b/src/lib/tests/run_trees.int.test.ts @@ -0,0 +1,269 @@ +import { Client } from "../client.js"; +import * as uuid from "../utils/uuid/src/index.js"; +import { + RunTree, + RunTreeConfig, + convertToDottedOrderFormat, +} from "../run_trees.js"; +import { + toArray, + waitUntil, + pollRunsUntilCount, + sanitizePresignedUrls, + skipIfTransientError, +} from "./utils.js"; +import { requiresClickhouse } from "./utils/markers.js"; + +test("Test post and patch run", async () => { + await skipIfTransientError(async () => { + const projectName = `__test_run_tree_js ${uuid.v4()}`; + const langchainClient = new Client({ timeout_ms: 30_000 }); + const parentRunConfig: RunTreeConfig = { + name: "parent_run", + run_type: "chain", + inputs: { text: "hello world" }, + project_name: projectName, + serialized: {}, + client: langchainClient, + }; + + const parent_run = new RunTree(parentRunConfig); + expect(parent_run.trace_id).toEqual(parent_run.id); + expect(parent_run.dotted_order).toEqual( + convertToDottedOrderFormat(parent_run.start_time, parent_run.id) + .dottedOrder, + ); + await parent_run.postRun(); + + const child_llm_run = parent_run.createChild({ + name: "child_run", + run_type: "llm", + inputs: { text: "hello world" }, + }); + expect(child_llm_run.dotted_order).toEqual( + parent_run.dotted_order + + "." + + convertToDottedOrderFormat( + child_llm_run.start_time, + child_llm_run.id, + 2, + ).dottedOrder, + ); + expect(child_llm_run.trace_id).toEqual(parent_run.trace_id); + await child_llm_run.postRun(); + + const child_chain_run = await parent_run.createChild({ + name: "child_chain_run", + run_type: "chain", + inputs: { text: "hello world" }, + }); + await child_chain_run.postRun(); + + const grandchild_chain_run = await child_chain_run.createChild({ + name: "grandchild_chain_run", + run_type: "chain", + inputs: { text: "hello world" }, + }); + await grandchild_chain_run.postRun(); + await grandchild_chain_run.end({ output: ["Hi"] }); + await grandchild_chain_run.patchRun(); + await child_chain_run.end(undefined, "AN ERROR"); + await child_chain_run.patchRun(); + const child_tool_run = await parent_run.createChild({ + name: "child_tool_run", + run_type: "tool", + inputs: { text: "hello world" }, + }); + await child_tool_run.postRun(); + await child_tool_run.end({ output: ["Hi"] }); + await child_tool_run.patchRun(); + await child_llm_run.end({ prompts: ["hello world"] }); + await child_llm_run.patchRun(); + await parent_run.end({ output: ["Hi"] }); + await parent_run.patchRun(); + + await waitUntil( + async () => { + try { + const runs = await toArray(langchainClient.listRuns({ projectName })); + return runs.length === 5; + } catch (_e) { + return false; + } + }, + 30_000, // Wait up to 30 seconds + 3000, // every 3 second + ); + const runs = await toArray(langchainClient.listRuns({ projectName })); + expect(runs.length).toEqual(5); + const runMap = new Map(runs.map((run) => [run.name, run])); + expect(runMap.get("child_run")?.parent_run_id).toEqual( + runMap.get("parent_run")?.id, + ); + expect(runMap.get("child_chain_run")?.parent_run_id).toEqual( + runMap.get("parent_run")?.id, + ); + expect(runMap.get("grandchild_chain_run")?.parent_run_id).toEqual( + runMap.get("child_chain_run")?.id, + ); + expect(runMap.get("child_tool_run")?.parent_run_id).toEqual( + runMap.get("parent_run")?.id, + ); + expect(runMap.get("parent_run")?.parent_run_id).toBeNull(); + await waitUntil( + async () => { + try { + const runs_ = await toArray( + langchainClient.listRuns({ traceId: runs[0].trace_id }), + ); + return runs_.length === 5; + } catch (_e) { + return false; + } + }, + 30_000, // Wait up to 30 seconds + 3000, // every 3 second + ); + + const traceRunsIter = langchainClient.listRuns({ + traceId: runs[0].trace_id, + }); + const traceRuns = await toArray(traceRunsIter); + expect(traceRuns.length).toEqual(5); + // Sort by dotted order and assert runs lists are equal + const sortedRuns = sanitizePresignedUrls( + runs.sort((a, b) => + (a?.dotted_order ?? "").localeCompare(b?.dotted_order ?? ""), + ), + ); + const sortedTraceRuns = sanitizePresignedUrls( + traceRuns.sort((a, b) => + (a?.dotted_order ?? "").localeCompare(b?.dotted_order ?? ""), + ), + ); + expect(sortedRuns).toEqual(sortedTraceRuns); + await langchainClient.deleteProject({ projectName }); + }); +}, 120_000); + +requiresClickhouse.test( + "Test list runs multi project", + async () => { + await skipIfTransientError(async () => { + const projectNames = [ + "__My JS Tracer Project - test_list_runs_multi_project", + "__My JS Tracer Project - test_list_runs_multi_project2", + ]; + + try { + const langchainClient = new Client({ timeout_ms: 30000 }); + + for (const project of projectNames) { + if (await langchainClient.hasProject({ projectName: project })) { + await langchainClient.deleteProject({ projectName: project }); + } + } + + const parentRunConfig: RunTreeConfig = { + name: "parent_run", + inputs: { text: "hello world" }, + project_name: projectNames[0], + client: langchainClient, + }; + + const parent_run = new RunTree(parentRunConfig); + await parent_run.postRun(); + await parent_run.end({ output: "Completed: foo" }); + await parent_run.patchRun(); + + const parentRunConfig2: RunTreeConfig = { + name: "parent_run", + inputs: { text: "hello world" }, + project_name: projectNames[1], + client: langchainClient, + }; + + const parent_run2 = new RunTree(parentRunConfig2); + await parent_run2.postRun(); + await parent_run2.end({ output: "Completed: foo" }); + await parent_run2.patchRun(); + await pollRunsUntilCount(langchainClient, projectNames[0], 1); + await pollRunsUntilCount(langchainClient, projectNames[1], 1); + + const runsIter = langchainClient.listRuns({ + projectName: projectNames, + }); + const runs = await toArray(runsIter); + + expect(runs.length).toBe(2); + expect( + runs.every((run) => run?.outputs?.["output"] === "Completed: foo"), + ).toBe(true); + expect(runs[0].session_id).not.toBe(runs[1].session_id); + } finally { + const langchainClient = new Client(); + + for (const project of projectNames) { + if (await langchainClient.hasProject({ projectName: project })) { + try { + await langchainClient.deleteProject({ projectName: project }); + } catch (_e) { + // Pass + } + } + } + } + }); + }, + 120_000, +); + +requiresClickhouse.test( + "Test end() write to metadata", + async () => { + const runId = uuid.v4(); + const projectName = `__test_end_metadata_run_tree_js ${runId}`; + const langchainClient = new Client({ timeout_ms: 30_000 }); + const parentRunConfig: RunTreeConfig = { + name: "parent_run", + id: runId, + run_type: "chain", + project_name: projectName, + client: langchainClient, + }; + + const parentRun = new RunTree(parentRunConfig); + await parentRun.end({ output: ["Hi"] }, undefined, undefined, { + final_metadata: runId, + }); + await parentRun.postRun(); + + await pollRunsUntilCount(langchainClient, projectName, 1); + const runs = await toArray(langchainClient.listRuns({ id: [runId] })); + expect(runs.length).toEqual(1); + expect(runs[0].extra); + await langchainClient.deleteProject({ projectName }); + }, + 120_000, +); + +requiresClickhouse.test("dotted order matches start_time", async () => { + const projectName = `__test_dotted_order_matches_start_time_run_tree_js ${uuid.v4()}`; + const langchainClient = new Client({ timeout_ms: 30_000 }); + const parentRunConfig: RunTreeConfig = { + name: "parent_run", + project_name: projectName, + client: langchainClient, + }; + const parentRun = new RunTree(parentRunConfig); + await parentRun.postRun(); + await parentRun.end({ output: "Completed: foo" }); + await parentRun.patchRun(); + await pollRunsUntilCount(langchainClient, projectName, 1); + const runs = await toArray(langchainClient.listRuns({ projectName })); + expect(runs.length).toEqual(1); + expect(runs[0].dotted_order).toEqual(parentRun.dotted_order); + expect((runs[0].start_time as string).replace(/[-:.Z]/g, "")).toEqual( + parentRun.dotted_order.split(".")[0].split("Z")[0], + ); +}); diff --git a/src/lib/tests/run_trees.test.ts b/src/lib/tests/run_trees.test.ts new file mode 100644 index 0000000..d06c0c4 --- /dev/null +++ b/src/lib/tests/run_trees.test.ts @@ -0,0 +1,944 @@ +/* eslint-disable no-process-env, @typescript-eslint/no-explicit-any */ + +import { jest } from "@jest/globals"; +import { Client } from "../client.js"; +import { RunTree } from "../run_trees.js"; +import { getCurrentRunTree, withRunTree } from "../singletons/traceable.js"; +import { traceable } from "../traceable.js"; +import { computeRunIdForSecondaryReplica } from "../uuid.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; + +const _DATE = 1620000000000; +Date.now = jest.fn(() => _DATE); + +test("Should work with manually set API key", async () => { + const key = process.env.LANGCHAIN_API_KEY; + delete process.env.LANGCHAIN_API_KEY; + try { + const callSpy = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + const langchainClient = new Client({ + autoBatchTracing: true, + callerOptions: { maxRetries: 0 }, + timeout_ms: 30_000, + apiKey: key, + fetchImplementation: callSpy, + }); + const projectName = "__test_persist_update_run_tree"; + const runTree = new RunTree({ + name: "Test Run Tree", + inputs: { input: "foo1" }, + client: langchainClient, + project_name: projectName, + }); + await runTree.postRun(); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(callSpy).toHaveBeenCalled(); + } finally { + process.env.LANGCHAIN_API_KEY = key; + } +}, 180_000); + +test("nested", () => { + const id = "00000000-0000-0000-0000-00000000000"; + const date = "20210503T00000000000"; + + const parent = new RunTree({ name: "parent_1", id: `${id}0` }); + const child1 = parent.createChild({ name: "child_1", id: `${id}1` }); + const child2 = parent.createChild({ name: "child_2", id: `${id}2` }); + const grandchild1 = child1.createChild({ + name: "grandchild_1", + id: `${id}3`, + }); + const grandchild2 = child1.createChild({ + name: "grandchild_2", + id: `${id}4`, + }); + const child3 = parent.createChild({ name: "child_3", id: `${id}5` }); + + expect(parent.dotted_order).toBe(`${date}1Z${id}0`); + expect(child1.dotted_order).toBe(`${date}1Z${id}0.${date}2Z${id}1`); + expect(child2.dotted_order).toBe(`${date}1Z${id}0.${date}3Z${id}2`); + expect(grandchild1.dotted_order).toBe( + `${date}1Z${id}0.${date}2Z${id}1.${date}3Z${id}3`, + ); + expect(grandchild2.dotted_order).toBe( + `${date}1Z${id}0.${date}2Z${id}1.${date}4Z${id}4`, + ); + expect(child3.dotted_order).toBe(`${date}1Z${id}0.${date}5Z${id}5`); +}); + +test("serializing run tree", () => { + const parent = new RunTree({ name: "parent_1" }); + parent.createChild({ name: "child_1" }).createChild({ name: "child_2" }); + parent.createChild({ name: "child_3" }); + + expect(JSON.parse(JSON.stringify(parent))).toMatchObject({ + name: "parent_1", + run_type: "chain", + child_runs: [ + { + name: "child_1", + run_type: "chain", + child_runs: [{ name: "child_2", run_type: "chain", child_runs: [] }], + }, + { + name: "child_3", + run_type: "chain", + child_runs: [], + }, + ], + }); +}); + +test("distributed", async () => { + process.env.LANGCHAIN_TRACING = "true"; + const { client, callSpy } = mockClient(); + const parent = new RunTree({ + name: "parent_1", + id: "00000000-0000-0000-0000-00000000000", + start_time: Date.parse("2021-05-03T00:00:00.000Z"), + project_name: "test_project", + client, + }); + + await parent.postRun(); + + const serialized = parent.toHeaders(); + expect(serialized.baggage).toContain("test_project"); + + const child2 = RunTree.fromHeaders(serialized, { + client, + })?.createChild({ + name: "child_2", + id: "00000000-0000-0000-0000-00000000001", + start_time: Date.parse("2021-05-03T00:00:01.000Z"), + }); + + await child2?.postRun(); + + expect(JSON.parse(JSON.stringify(child2))).toMatchObject({ + name: "child_2", + run_type: "chain", + session_name: "test_project", + dotted_order: + "20210503T000000000001Z00000000-0000-0000-0000-00000000000.20210503T000001000002Z00000000-0000-0000-0000-00000000001", + }); + + const result = await withRunTree(child2!, () => { + return traceable( + () => { + const currentRunTree = getCurrentRunTree(); + expect(currentRunTree.id).toBe("00000000-0000-0000-0000-00000000002"); + expect(currentRunTree.dotted_order).toBe( + "20210503T000000000001Z00000000-0000-0000-0000-00000000000.20210503T000001000002Z00000000-0000-0000-0000-00000000001.20210503T000002000003Z00000000-0000-0000-0000-00000000002", + ); + return "child2"; + }, + { + name: "grandchild_traceable", + client, + id: "00000000-0000-0000-0000-00000000002", + start_time: Date.parse("2021-05-03T00:00:02.000Z"), + }, + )(); + }); + + expect(result).toBe("child2"); + await child2?.end(); + await child2?.patchRun(); + await parent.end(); + await parent.patchRun(); + await client.awaitPendingTraceBatches(); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parent_1:0", "child_2:1", "grandchild_traceable:2"], + }); +}); + +test("shared client between run trees", () => { + const runTree1 = new RunTree({ name: "tree_1" }); + const runTree2 = new RunTree({ name: "tree_2" }); + + expect(runTree1.client).toBeDefined(); + expect(runTree2.client).toBeDefined(); + expect(runTree1.client).toBe(runTree2.client); +}); + +test("reroot functionality slices dotted order correctly", () => { + // Create a parent run tree + const parentId = "00000000-0000-0000-0000-000000000000"; + const parent = new RunTree({ + name: "parent", + id: parentId, + project_name: "main-project", + }); + + // Create child runs + const child1Id = "11111111-1111-1111-1111-111111111111"; + const child1 = parent.createChild({ name: "child1", id: child1Id }); + + const child2Id = "22222222-2222-2222-2222-222222222222"; + const child2 = child1.createChild({ name: "child2", id: child2Id }); + + // Create a replica with reroot enabled + const replicaConfig = { + projectName: "child-project", + reroot: true, + }; + + // Set distributed parent ID (simulating fromHeaders behavior) + child2.distributedParentId = child1Id; + child2.replicas = [replicaConfig]; + + // Use private method to remap for project with reroot + const remapped = (child2 as any)._remapForProject({ + projectName: "child-project", + runtimeEnv: undefined, + excludeChildRuns: true, + reroot: true, + distributedParentId: child1Id, + }); + + // Verify that the dotted order was sliced + // Original dotted order should have parent -> child1 -> child2 + // After slicing at child1, it should only have child2 + const originalSegments = child2.dotted_order.split("."); + const remappedSegments = remapped.dotted_order.split("."); + + // The remapped version should have fewer segments + expect(remappedSegments.length).toBeLessThan(originalSegments.length); + + // The parent_run_id should be undefined after rerooting at the immediate parent + if (child2.parent_run_id === child1Id) { + expect(remapped.parent_run_id).toBeUndefined(); + } + + // The trace_id should be updated to the new root + expect(remapped.trace_id).toBeTruthy(); +}); + +test("reroot with fromHeaders sets distributedParentId", () => { + const parentDottedOrder = + "20210503T000000000000000000000000-0000-0000-0000-000000000000"; + const headers = { + "langsmith-trace": parentDottedOrder, + }; + + const runTree = RunTree.fromHeaders(headers); + + expect(runTree).toBeDefined(); + expect(runTree!.distributedParentId).toBe(runTree!.id); +}); + +test("distributed tracing: _sliceParentId method", () => { + const { client } = mockClient(); + + // Create a 3-level hierarchy: grandparent -> parent -> child + const grandparent = new RunTree({ + name: "Grandparent", + inputs: { text: "root" }, + client: client, + }); + + const parent = grandparent.createChild({ name: "Parent" }); + const child = parent.createChild({ name: "Child" }); + + const parentId = parent.id; + + // Get the child's run data + const childDict = (child as any)._convertToCreate(child, undefined, true); + + // Verify initial state + expect(childDict.parent_run_id).toBe(parent.id); + expect(childDict.trace_id).toBe(grandparent.id); + expect(childDict.dotted_order).toBeTruthy(); + + // Slice at parent ID + (child as any)._sliceParentId(parentId, childDict); + + // After slicing, parent_run_id should be undefined + expect(childDict.parent_run_id).toBeUndefined(); + + // Trace ID should now be the child's ID + expect(childDict.trace_id).toBe(child.id); + + // Dotted order should have only 1 segment (just the child) + const segments = childDict.dotted_order.split("."); + expect(segments.length).toBe(1); +}); + +test("distributed tracing: _remapForProject with reroot", () => { + const { client } = mockClient(); + + const grandparent = new RunTree({ + name: "Grandparent", + inputs: { text: "root" }, + client: client, + project_name: "original_project", + }); + + const parent = grandparent.createChild({ name: "Parent" }); + const child = parent.createChild({ name: "Child" }); + + const parentId = parent.id; + + // Simulate distributed parent ID being set + child.distributedParentId = parentId; + + // Test WITH reroot enabled + const remappedWithReroot = (child as any)._remapForProject({ + projectName: "child_project", + runtimeEnv: undefined, + excludeChildRuns: true, + reroot: true, // reroot enabled + distributedParentId: parentId, + }); + + expect(remappedWithReroot.parent_run_id).toBeUndefined(); + expect(remappedWithReroot.session_name).toBe("child_project"); + + const segmentsWithReroot = remappedWithReroot.dotted_order.split("."); + expect(segmentsWithReroot.length).toBe(1); + + // Test WITHOUT reroot (should keep parent relationship) + const remappedWithoutReroot = (child as any)._remapForProject({ + projectName: "child_project_2", + runtimeEnv: undefined, + excludeChildRuns: true, + reroot: false, // reroot disabled + distributedParentId: parentId, + }); + + expect(remappedWithoutReroot.parent_run_id).toBeTruthy(); + + // Test with no reroot parameter (should keep parent relationship) + const remappedNoParam = (child as any)._remapForProject({ + projectName: "child_project_3", + runtimeEnv: undefined, + excludeChildRuns: true, + distributedParentId: parentId, + }); + + expect(remappedNoParam.parent_run_id).toBeTruthy(); +}); + +test("_remapForProject honors replica primary semantics", () => { + const { client } = mockClient(); + const run = new RunTree({ + name: "run", + inputs: {}, + client, + project_name: "original-project", + }); + const remap = (projectName: string, primary?: boolean) => + (run as any)._remapForProject({ projectName, primary }); + + expect(remap("different-project", true).id).toBe(run.id); + expect(remap("original-project", false).id).toBe( + computeRunIdForSecondaryReplica(run.id, "original-project"), + ); + expect(remap("original-project").id).toBe(run.id); + expect(remap("different-project").id).toBe( + computeRunIdForSecondaryReplica(run.id, "different-project"), + ); +}); + +test("distributed tracing: fromHeaders sets distributedParentId correctly", () => { + const { client } = mockClient(); + + // Create a hierarchy: grandparent -> parent -> child + const grandparent = new RunTree({ + name: "Grandparent", + inputs: { text: "grandparent" }, + client: client, + project_name: "original_project", + }); + + const parent = grandparent.createChild({ name: "Parent" }); + const child = parent.createChild({ name: "Child" }); + + // Get headers from child + const headers = child.toHeaders(); + + // Create a new run tree from headers + const fromHeadersRun = RunTree.fromHeaders(headers); + + expect(fromHeadersRun).toBeDefined(); + expect(fromHeadersRun!.distributedParentId).toBe(fromHeadersRun!.id); + expect(fromHeadersRun!.distributedParentId).toBe(child.id); + + // Now create a new run that should use this distributed parent ID + const newRun = new RunTree({ + name: "NewRun", + inputs: { text: "new_run" }, + client: client, + project_name: "child_project", + }); + + newRun.distributedParentId = fromHeadersRun!.id; + + // Remap with reroot enabled + const remapped = (newRun as any)._remapForProject({ + projectName: "child_project", + runtimeEnv: undefined, + excludeChildRuns: true, + reroot: true, + distributedParentId: newRun.distributedParentId, + }); + + expect(remapped.parent_run_id).toBeUndefined(); + + const segments = remapped.dotted_order.split("."); + expect(segments.length).toBe(1); + expect(remapped.trace_id).toBe(newRun.id); +}); + +test("_sliceParentId handles missing parent gracefully", () => { + const { client } = mockClient(); + + const parent = new RunTree({ + name: "Parent", + inputs: { text: "parent" }, + client: client, + }); + + const child = parent.createChild({ name: "Child" }); + + const childDict = (child as any)._convertToCreate(child, undefined, true); + const originalDottedOrder = childDict.dotted_order; + const originalParentRunId = childDict.parent_run_id; + + // Try to slice with a non-existent parent ID + const nonExistentId = "99999999-9999-9999-9999-999999999999"; + (child as any)._sliceParentId(nonExistentId, childDict); + + // Should not modify dotted order if parent not found + expect(childDict.dotted_order).toBe(originalDottedOrder); + + // Should not modify parent_run_id if it doesn't match + expect(childDict.parent_run_id).toBe(originalParentRunId); +}); + +test("_sliceParentId handles empty dotted order segments", () => { + const { client } = mockClient(); + + const singleRun = new RunTree({ + name: "SingleRun", + inputs: { text: "single" }, + client: client, + }); + + const runDict = (singleRun as any)._convertToCreate( + singleRun, + undefined, + true, + ); + + // Try to slice at the run's own ID (should result in empty segments) + (singleRun as any)._sliceParentId(singleRun.id, runDict); + + // When all segments are removed, trace_id should be set to the run's ID + expect(runDict.trace_id).toBe(singleRun.id); +}); + +test("integration: reroot with replicas makes correct API calls", async () => { + const callSpy = jest.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(""), + } as Response); + + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 0 }, + timeout_ms: 30_000, + apiKey: "test-key", + fetchImplementation: callSpy, + }); + + // Create a 3-level hierarchy + const grandparent = new RunTree({ + name: "Grandparent", + inputs: { text: "root" }, + client: client, + project_name: "main-project", + }); + + const parent = grandparent.createChild({ name: "Parent" }); + const child = parent.createChild({ name: "Child" }); + + // Set up replicas - one with reroot, one without + child.distributedParentId = parent.id; + child.replicas = [ + { + projectName: "child-project-rerooted", + apiKey: "replica1-key", + apiUrl: "https://replica1.example.com", + reroot: true, + }, + { + projectName: "child-project-full-trace", + apiKey: "replica2-key", + apiUrl: "https://replica2.example.com", + reroot: false, + }, + ]; + + // Post the run + await child.postRun(); + + // Verify that createRun was called twice (once per replica) + expect(callSpy).toHaveBeenCalledTimes(2); + + // Verify the URLs called + const calls = callSpy.mock.calls; + expect(calls[0][0]).toBe("https://replica1.example.com/runs"); + expect(calls[1][0]).toBe("https://replica2.example.com/runs"); + + // Verify that both calls include the Authorization header with different keys + const call1Headers = (calls[0][1] as any)?.headers; + const call2Headers = (calls[1][1] as any)?.headers; + + expect(call1Headers?.["x-api-key"]).toBe("replica1-key"); + expect(call2Headers?.["x-api-key"]).toBe("replica2-key"); + + // The actual rerooting logic is already tested in unit tests + // This integration test just verifies the API calls are made to the correct endpoints +}, 180_000); + +test("child_runs array is cleared after postRun to prevent memory leak", async () => { + const { client } = mockClient(); + + const parent = new RunTree({ + name: "Parent", + inputs: { text: "parent" }, + client: client, + }); + + const child1 = parent.createChild({ name: "Child1" }); + const child2 = parent.createChild({ name: "Child2" }); + + // Before posting, parent should have references to children + expect(parent.child_runs).toHaveLength(2); + expect(parent.child_runs).toContain(child1); + expect(parent.child_runs).toContain(child2); + + // Post the parent run + await parent.postRun(); + + // After posting, child_runs should be cleared to prevent memory leak + expect(parent.child_runs).toHaveLength(0); +}); + +test("child_runs array is cleared after patchRun to prevent memory leak", async () => { + const { client } = mockClient(); + + const parent = new RunTree({ + name: "Parent", + inputs: { text: "parent" }, + client: client, + }); + + parent.createChild({ name: "Child1" }); + parent.createChild({ name: "Child2" }); + + // Before patching, parent should have references to children + expect(parent.child_runs).toHaveLength(2); + + // Patch the parent run + await parent.patchRun(); + + // After patching, child_runs should be cleared to prevent memory leak + expect(parent.child_runs).toHaveLength(0); +}); + +test("memory leak test with multiple iterations", async () => { + const { client } = mockClient(); + + // Simulate multiple requests creating parent-child hierarchies + for (let i = 0; i < 10; i++) { + const parent = new RunTree({ + name: `Parent${i}`, + inputs: { iteration: i }, + client: client, + }); + + // Each parent creates multiple children + parent.createChild({ name: `Child${i}-1` }); + parent.createChild({ name: `Child${i}-2` }); + parent.createChild({ name: `Child${i}-3` }); + + expect(parent.child_runs).toHaveLength(3); + + // Post the run + await parent.postRun(); + + // After posting, array should be cleared + expect(parent.child_runs).toHaveLength(0); + } +}); + +test("createChild propagates thread_id from parent when child does not set it", () => { + const { client } = mockClient(); + const root = new RunTree({ + name: "Root", + inputs: {}, + client, + extra: { metadata: { thread_id: "thread-abc" } }, + }); + const child = root.createChild({ name: "Child" }); + expect(child.extra?.metadata?.thread_id).toBe("thread-abc"); + const childOverride = root.createChild({ + name: "Child2", + extra: { metadata: { thread_id: "child-override" } }, + }); + expect(childOverride.extra?.metadata?.thread_id).toBe("child-override"); + const grandchild = child.createChild({ name: "Grandchild" }); + expect(grandchild.extra?.metadata?.thread_id).toBe("thread-abc"); +}); + +test("fromHeaders filters replica credentials", () => { + const replicas = [ + { + apiKey: "injected-key", + apiUrl: "https://evil.com/exfil", + projectName: "legit-project", + primary: true, + updates: { reroot: true }, + }, + ]; + const baggage = `langsmith-replicas=${encodeURIComponent( + JSON.stringify(replicas), + )}`; + const headers = { + "langsmith-trace": + "20240101T000000000000Z00000000-0000-0000-0000-000000000001", + baggage, + }; + + const parsed = RunTree.fromHeaders(headers); + + expect(parsed).toBeDefined(); + expect(parsed!.replicas).toBeDefined(); + expect(parsed!.replicas).toHaveLength(1); + + const replica = parsed!.replicas![0] as Record; + expect(replica.apiKey).toBeUndefined(); + expect(replica.apiUrl).toBeUndefined(); + expect(replica.projectName).toBe("legit-project"); + expect(replica.primary).toBe(true); + expect(replica.updates).toEqual({ reroot: true }); +}); + +test("fromHeaders drops a non-boolean replica primary value", () => { + const baggage = `langsmith-replicas=${encodeURIComponent( + JSON.stringify([{ projectName: "replica-project", primary: "false" }]), + )}`; + const parsed = RunTree.fromHeaders({ + "langsmith-trace": + "20240101T000000000000Z00000000-0000-0000-0000-000000000001", + baggage, + }); + + expect(parsed).toBeDefined(); + const replica = parsed!.replicas![0]; + expect(replica.primary).toBeUndefined(); + const remapped = (parsed as any)._remapForProject(replica); + expect(remapped.id).not.toBe(parsed!.id); +}); + +describe("LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", () => { + const originalLangSmithValue = process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH; + const originalLangChainValue = process.env.LANGCHAIN_EXCLUDE_INPUTS_ON_PATCH; + + beforeEach(() => { + delete process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH; + delete process.env.LANGCHAIN_EXCLUDE_INPUTS_ON_PATCH; + }); + + afterEach(() => { + if (originalLangSmithValue === undefined) { + delete process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH; + } else { + process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH = originalLangSmithValue; + } + if (originalLangChainValue === undefined) { + delete process.env.LANGCHAIN_EXCLUDE_INPUTS_ON_PATCH; + } else { + process.env.LANGCHAIN_EXCLUDE_INPUTS_ON_PATCH = originalLangChainValue; + } + }); + + test.each([ + ["env unset", undefined, undefined, undefined, false], + ["true", "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", "true", undefined, false], + ["TRUE", "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", "TRUE", undefined, false], + ["1", "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", "1", undefined, false], + ["false", "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", "false", undefined, true], + ["bogus", "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", "bogus", undefined, true], + [ + "LANGCHAIN namespace", + "LANGCHAIN_EXCLUDE_INPUTS_ON_PATCH", + "true", + undefined, + false, + ], + [ + "explicit false override", + "LANGSMITH_EXCLUDE_INPUTS_ON_PATCH", + "true", + false, + true, + ], + ["explicit true override", undefined, undefined, true, false], + ] as const)( + "%s", + async (_name, envName, envValue, explicit, expectInputs) => { + if (envName !== undefined && envValue !== undefined) { + process.env[envName] = envValue; + } + + const { client } = mockClient(); + const updateSpy = jest.spyOn(client, "updateRun"); + const runTree = new RunTree({ + name: "test-run", + inputs: { a: 1 }, + client, + }); + + await runTree.patchRun( + explicit === undefined ? undefined : { excludeInputs: explicit }, + ); + + const payload = updateSpy.mock.calls[0][1]; + expect("inputs" in payload).toBe(expectInputs); + if (expectInputs) { + expect(payload.inputs).toEqual({ a: 1 }); + } + }, + ); + + test("applies to replica patches", async () => { + process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH = "true"; + const { client } = mockClient(); + const updateSpy = jest.spyOn(client, "updateRun"); + const runTree = new RunTree({ + name: "test-run", + inputs: { a: 1 }, + client, + project_name: "test-project", + replicas: [{ projectName: "replica-project" }], + }); + + await runTree.patchRun(); + + expect("inputs" in updateSpy.mock.calls[0][1]).toBe(false); + }); + + test("reads the environment for each implicit patch", async () => { + const { client } = mockClient(); + const updateSpy = jest.spyOn(client, "updateRun"); + const runTree = new RunTree({ + name: "test-run", + inputs: { a: 1 }, + client, + }); + + await runTree.patchRun(); + expect("inputs" in updateSpy.mock.calls[0][1]).toBe(false); + + process.env.LANGSMITH_EXCLUDE_INPUTS_ON_PATCH = "false"; + await runTree.patchRun(); + expect(updateSpy.mock.calls[1][1].inputs).toEqual({ a: 1 }); + }); +}); + +describe("primary replica owns the original run ids", () => { + // A replica for the run's own project must not keep the original ids when + // another replica is primary: the backend would see one run id in two + // projects and route every run of the trace to only one of them. + const OWN = "document-summarization"; + + const idsByProject = (calls: any[]) => + Object.fromEntries(calls.map(([run]) => [run.session_name, run.id])); + + test("own-project replica is remapped on post and patch", async () => { + const { client } = mockClient(); + const createRun = jest + .spyOn(client, "createRun") + .mockResolvedValue(undefined as any); + const updateRun = jest + .spyOn(client, "updateRun") + .mockResolvedValue(undefined as any); + const run = new RunTree({ + name: "agent", + inputs: {}, + client, + project_name: OWN, + replicas: [ + { projectName: "production", primary: true }, + { projectName: OWN }, + ], + }); + + await run.postRun(); + await run.end({}); + await run.patchRun(); + + const posted = idsByProject(createRun.mock.calls); + expect(posted.production).toBe(run.id); + expect(posted[OWN]).toBe(computeRunIdForSecondaryReplica(run.id, OWN)); + const patched = Object.fromEntries( + updateRun.mock.calls.map(([runId, update]: any[]) => [ + update.session_name, + runId, + ]), + ); + expect(patched.production).toBe(run.id); + expect(patched[OWN]).toBe(computeRunIdForSecondaryReplica(run.id, OWN)); + }); + + test("without a primary, the own-project replica keeps the original ids", async () => { + const { client } = mockClient(); + const createRun = jest + .spyOn(client, "createRun") + .mockResolvedValue(undefined as any); + const run = new RunTree({ + name: "agent", + inputs: {}, + client, + project_name: OWN, + replicas: [{ projectName: OWN }, { projectName: "audit" }], + }); + + await run.postRun(); + + const posted = idsByProject(createRun.mock.calls); + expect(posted[OWN]).toBe(run.id); + expect(posted.audit).toBe(computeRunIdForSecondaryReplica(run.id, "audit")); + }); + + describe.each([ + ["replica without project", {}], + ["replica for own project", { project_name: OWN }], + ])("LANGSMITH_RUNS_ENDPOINTS with a primary plus a %s", (_label, second) => { + const originalEnv = process.env; + afterEach(() => { + process.env = originalEnv; + }); + + test("does not reuse ids across projects", async () => { + const endpoint = "https://api.smith.langchain.com"; + process.env = { + ...originalEnv, + LANGSMITH_PROJECT: OWN, + LANGSMITH_RUNS_ENDPOINTS: JSON.stringify([ + { + api_url: endpoint, + api_key: "k", + project_name: "production", + primary: true, + }, + { api_url: endpoint, api_key: "k", ...second }, + ]), + }; + const { client } = mockClient(); + const createRun = jest + .spyOn(client, "createRun") + .mockResolvedValue(undefined as any); + const run = new RunTree({ name: "agent", inputs: {}, client }); + + await run.postRun(); + + const posted = createRun.mock.calls.map(([r]: any[]) => r); + expect(posted).toHaveLength(2); + expect(new Set(posted.map((r) => r.session_name)).size).toBe(2); + expect(new Set(posted.map((r) => r.id)).size).toBe(2); + expect(idsByProject(createRun.mock.calls).production).toBe(run.id); + }); + }); +}); + +describe("batch ingest with one run id in two projects", () => { + const ID = "01a0f16c-551e-71b2-afda-7dcd1fac635b"; + const ref = { + id: ID, + trace_id: ID, + dotted_order: `20260930T082644000000Z${ID}`, + }; + const create = (sessionName: string) => ({ + ...ref, + name: "agent", + run_type: "chain", + inputs: {}, + start_time: Date.now(), + session_name: sessionName, + }); + const update = { + ...ref, + end_time: Date.now(), + outputs: {}, + }; + + afterEach(() => jest.restoreAllMocks()); + + test("warns every time a create is dropped for another project", async () => { + const { client } = mockClient(); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + await client.multipartIngestRuns({ + runCreates: [create("production"), create("document-summarization")], + runUpdates: [update], + }); + await client.multipartIngestRuns({ + runCreates: [create("production"), create("document-summarization")], + runUpdates: [update], + }); + + const collisions = warn.mock.calls.filter(([m]) => + String(m).includes("two projects with the same id"), + ); + expect(collisions).toHaveLength(2); + expect(String(collisions[0][0])).toContain("production"); + expect(String(collisions[0][0])).toContain("document-summarization"); + }); + + test.each(["batchIngestRuns", "multipartIngestRuns"] as const)( + "%s warns for creates-only batches and still sends both", + async (method) => { + const { client, callSpy } = mockClient(); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + await client[method]({ + runCreates: [create("production"), create("document-summarization")], + }); + + expect( + warn.mock.calls.filter(([m]) => + String(m).includes("two projects with the same id"), + ), + ).toHaveLength(1); + const [, init] = callSpy.mock.calls.at(-1); + const body = await new Response(init.body).text(); + expect(body).toContain("production"); + expect(body).toContain("document-summarization"); + }, + ); + + test("does not warn for duplicate creates to one project", async () => { + const { client } = mockClient(); + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + await client.multipartIngestRuns({ + runCreates: [create("production"), create("production")], + runUpdates: [update], + }); + + expect( + warn.mock.calls.filter(([m]) => + String(m).includes("two projects with the same id"), + ), + ).toHaveLength(0); + }); +}); diff --git a/src/lib/tests/run_trees.uuid.test.ts b/src/lib/tests/run_trees.uuid.test.ts new file mode 100644 index 0000000..2df26ea --- /dev/null +++ b/src/lib/tests/run_trees.uuid.test.ts @@ -0,0 +1,278 @@ +import { jest } from "@jest/globals"; +import { v4 as uuidv4, v7 as uuidv7 } from "../utils/uuid/src/index.js"; +import { computeRunIdForSecondaryReplica } from "../index.js"; +import { RunTree } from "../run_trees.js"; +import { traceable } from "../traceable.js"; +import { + getUuidVersion, + nonCryptographicUuid7Deterministic, +} from "../utils/_uuid.js"; +import { mockClient } from "./utils/mock_client.js"; + +function uuidV7Ms(uuidStr: string): number { + // Remove dashes to parse + const hex = uuidStr.replace(/-/g, ""); + // First 12 hex chars = 48 bits of Unix ms timestamp + const tsHex = hex.slice(0, 12); + return parseInt(tsHex, 16); +} + +test("traceable produces UUIDv7 and start_time matches run id", async () => { + const fixedMs = 1_700_000_123_456; // deterministic timestamp in ms + const { client } = mockClient(); + const createSpy = jest.spyOn(client, "createRun"); + const updateSpy = jest.spyOn(client, "updateRun"); + + const fn = traceable((x: number) => x + 1, { + name: "traceable-v7", + run_type: "chain", + client, + start_time: fixedMs, + tracingEnabled: true, + }); + + const out = await fn(1); + expect(out).toBe(2); + + await client.awaitPendingTraceBatches(); + + // Ensure post and patch include run_type and start_time + expect(createSpy).toHaveBeenCalled(); + const createdArg = createSpy.mock.calls[0][0]; + expect(createdArg.run_type).toBe("chain"); + expect(createdArg.start_time).toBeDefined(); + + // patch happens after end + expect(updateSpy).toHaveBeenCalled(); + const updatedArg = updateSpy.mock.calls[0][1]; + expect(updatedArg.run_type).toBe("chain"); + expect(updatedArg.start_time).toBeDefined(); + + // UUID version v7 + const createdId: string = createdArg.id!; + expect(getUuidVersion(createdId)).toBe(7); + // start_time encoded in v7 should match provided ms + expect(uuidV7Ms(createdId)).toBe(fixedMs); +}); + +test("RunTree default/regular behavior uses UUIDv7 and start_time matches id; post/patch include fields", async () => { + const fixedMs = 1_700_111_222_333; + const { client } = mockClient(); + const createSpy2 = jest.spyOn(client, "createRun"); + const updateSpy2 = jest.spyOn(client, "updateRun"); + + const rt = new RunTree({ + name: "regular-v7", + run_type: "tool", + start_time: fixedMs, + client, + tracingEnabled: true, + }); + + await rt.postRun(); + await rt.patchRun(); + + expect(createSpy2).toHaveBeenCalledTimes(1); + const createdArg2 = createSpy2.mock.calls[0][0]; + expect(createdArg2.run_type).toBe("tool"); + expect(createdArg2.start_time).toBeDefined(); + + expect(updateSpy2).toHaveBeenCalledTimes(1); + const updatedArg2 = updateSpy2.mock.calls[0][1]; + expect(updatedArg2.run_type).toBe("tool"); + expect(updatedArg2.start_time).toBeDefined(); + + // v7 id and encoded time + expect(getUuidVersion(rt.id)).toBe(7); + expect(uuidV7Ms(rt.id)).toBe(fixedMs); +}); + +test("nonCryptographicUuid7Deterministic produces valid, deterministic UUID7s", () => { + const original = uuidv7(); + const key = "replica-project"; + + const d1 = nonCryptographicUuid7Deterministic(original, key); + const d2 = nonCryptographicUuid7Deterministic(original, key); + + // Valid UUID7 + expect(getUuidVersion(d1)).toBe(7); + + expect(d1).toBe(d2); + + // Different inputs -> different outputs + expect(nonCryptographicUuid7Deterministic(original, "other-key")).not.toBe( + d1, + ); + expect(nonCryptographicUuid7Deterministic(uuidv7(), key)).not.toBe(d1); +}); + +test("nonCryptographicUuid7Deterministic timestamp handling", async () => { + // UUID7 input: timestamp preserved + const originalV7 = uuidv7(); + const derivedV7 = nonCryptographicUuid7Deterministic(originalV7, "key"); + expect(uuidV7Ms(derivedV7)).toBe(uuidV7Ms(originalV7)); + + // UUID4 input: no timestamp to preserve, so the field is hash-derived. Still + // a valid v7, and independent of when it was derived. + const originalV4 = uuidv4(); + const derivedV4 = nonCryptographicUuid7Deterministic(originalV4, "key"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(getUuidVersion(derivedV4)).toBe(7); + expect(nonCryptographicUuid7Deterministic(originalV4, "key")).toBe(derivedV4); +}); + +test("computeRunIdForSecondaryReplica returns the replica run ID", () => { + const original = "019c0711-e1aa-7223-bf21-12119afe80f7"; + + const remapped = computeRunIdForSecondaryReplica(original, "replica-project"); + + expect(remapped).toBe("019c0711-e1aa-7817-8301-943c0df9a3cd"); + expect( + computeRunIdForSecondaryReplica(original.toUpperCase(), "replica-project"), + ).toBe(remapped); + expect(() => computeRunIdForSecondaryReplica(original, "")).toThrow( + "projectName", + ); + expect(() => + computeRunIdForSecondaryReplica(uuidv4(), "replica-project"), + ).toThrow("UUID v7"); +}); + +test("internal replica remapping accepts non-UUIDv7 run IDs", () => { + const { client } = mockClient(); + const run = new RunTree({ + id: uuidv4(), + name: "test", + inputs: {}, + client, + project_name: "original-project", + }); + + const remapped = (run as any)._remapForProject({ + projectName: "replica-project", + primary: false, + }); + + expect(getUuidVersion(remapped.id)).toBe(7); +}); + +test("nonCryptographicUuid7Deterministic produces expected values", () => { + // Test with hard-coded values to ensure compatibility across implementations + // These values should match the Python implementation exactly + + const testCases = [ + { + input: "019c0711-e1aa-7223-bf21-12119afe80f7", + key: "other-key", + expected: "019c0711-e1aa-7614-833c-500ba8e2e686", + }, + { + input: "019c0711-e1aa-7223-bf21-12119afe80f7", + key: "replica-project", + expected: "019c0711-e1aa-7817-8301-943c0df9a3cd", + }, + { + input: "01900000-0000-7000-8000-000000000000", + key: "test", + expected: "01900000-0000-7132-8131-f80e352488a3", + }, + { + // Non-UUIDv7 original: no timestamp to preserve, so all 122 derived bits + // come from the hash. The other cases only cover the v7 branch. + input: "00000000-0000-4000-8000-000000000000", + key: "some-project", + expected: "a9457421-4152-7a3c-9bd5-db046e6cb943", + }, + ]; + + for (const { input, key, expected } of testCases) { + const result = nonCryptographicUuid7Deterministic(input, key); + expect(result).toBe(expected); + expect(getUuidVersion(result)).toBe(7); + } +}); + +test("nonCryptographicUuid7Deterministic is fast", async () => { + const originalUuids = []; + for (let i = 0; i < 100000; i++) { + originalUuids.push(uuidv7()); + } + const finalUuids = []; + const startTime = new Date(); + for (let i = 0; i < originalUuids.length; i++) { + finalUuids.push( + nonCryptographicUuid7Deterministic(originalUuids[i], "key"), + ); + } + const endTime = new Date(); + const duration = endTime.getTime() - startTime.getTime(); + expect(duration).toBeLessThan(10000); // 10k UUIDs/second to be conservative for CI, may be much higher + + // Check all UUIDs are valid v7 + for (let i = 0; i < finalUuids.length; i++) { + expect(getUuidVersion(finalUuids[i])).toBe(7); + } + + // Check uniqueness - different input UUIDs should produce different outputs + const uniqueOutputs = new Set(finalUuids); // First 1000 should all be unique + expect(uniqueOutputs.size).toBe(100000); +}); + +// the derivation must be a pure function of (originalId, key) -- see the +// JSDoc on nonCryptographicUuid7Deterministic. + +test("a non-UUIDv7 run reaches its replica under one id for post and patch", async () => { + const { client } = mockClient(); + const createSpy = jest.spyOn(client, "createRun"); + const updateSpy = jest.spyOn(client, "updateRun"); + + const rt = new RunTree({ + name: "r", + run_type: "chain", + id: uuidv4(), // legacy, non-UUIDv7 + client, + project_name: "primary", + tracingEnabled: true, + replicas: [{ projectName: "secondary-project" }], + }); + await rt.postRun(); + await new Promise((resolve) => setTimeout(resolve, 10)); + await rt.end({ ok: true }); + await rt.patchRun(); + await client.awaitPendingTraceBatches(); + + const postedId = createSpy.mock.calls[0][0].id; + const patchedId = updateSpy.mock.calls[0][0]; + expect(postedId).toBe(patchedId); +}); + +test("an ancestor's replica id is the same wherever it is derived", async () => { + // _remapForProject derives an ancestor's id from the ancestor itself and again + // from every descendant's trace_id, parent_run_id and dotted_order segments. + // All of them must agree or the replica's tree splits. + const { client } = mockClient(); + const createSpy = jest.spyOn(client, "createRun"); + + const parent = new RunTree({ + name: "parent", + run_type: "chain", + id: uuidv4(), // legacy, non-UUIDv7 + client, + project_name: "primary", + tracingEnabled: true, + replicas: [{ projectName: "secondary-project" }], + }); + await parent.postRun(); + // Different millisecond: a clock-derived timestamp would diverge here. + await new Promise((resolve) => setTimeout(resolve, 10)); + const child = parent.createChild({ name: "child", run_type: "chain" }); + await child.postRun(); + await client.awaitPendingTraceBatches(); + + const parentPost = createSpy.mock.calls[0][0]; + const childPost = createSpy.mock.calls[1][0]; + expect(childPost.trace_id).toBe(parentPost.id); + expect(childPost.parent_run_id).toBe(parentPost.id); + expect(childPost.dotted_order!.split(".")[0].slice(-36)).toBe(parentPost.id); +}); diff --git a/src/lib/tests/runs_share.int.test.ts b/src/lib/tests/runs_share.int.test.ts new file mode 100644 index 0000000..ccb1593 --- /dev/null +++ b/src/lib/tests/runs_share.int.test.ts @@ -0,0 +1,98 @@ +/** + * Smoke integration tests for the v2 runs.share resource. + * + * Covers `Client.runs.share` (create, delete) against a live backend. + */ +import { Client } from "../client.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { + deleteProject, + pollRunsUntilCount, + resolveTraceId, + waitUntil, +} from "./utils.js"; + +async function setUpProjectWithRun(client: Client) { + const projectName = `__test_v2_runs_share_${uuidv4().slice(0, 12)}`; + if (await client.hasProject({ projectName })) { + await deleteProject(client, projectName); + } + + const runId = uuidv4(); + const now = new Date(); + await client.createRun({ + id: runId, + name: "run_1", + inputs: { i: 1 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime(), + }); + await pollRunsUntilCount(client, projectName, 1, 30_000); + + const project = await client.readProject({ projectName }); + return { + projectName, + runId, + traceId: await resolveTraceId(client, runId, project.id), + projectId: project.id, + }; +} + +test("runs.share.create mints a share token for a run's trace root", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, runId, traceId, projectId } = + await setUpProjectWithRun(client); + try { + let shareToken: string | undefined; + // share.create resolves the trace root from SmithDB, which may lag indexing. + await waitUntil( + async () => { + const resp = await client.runs.share.create(runId, { + session_id: projectId, + trace_id: traceId, + }); + shareToken = resp.share_token; + return !!shareToken; + }, + 90_000, + 3_000, + ); + expect(shareToken).toBeDefined(); + expect(typeof shareToken).toBe("string"); + } finally { + await deleteProject(client, projectName); + } +}); + +test("runs.share.delete removes the share token and is idempotent", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, runId, traceId, projectId } = + await setUpProjectWithRun(client); + try { + await waitUntil( + async () => { + const resp = await client.runs.share.create(runId, { + session_id: projectId, + trace_id: traceId, + }); + return !!resp.share_token; + }, + 90_000, + 3_000, + ); + + // Delete resolves (204) and is idempotent: a second delete also succeeds. + // Success is "does not reject". + await client.runs.share.delete(traceId, { session_id: projectId }); + await client.runs.share.delete(traceId, { session_id: projectId }); + } finally { + await deleteProject(client, projectName); + } +}); diff --git a/src/lib/tests/sandbox.test.ts b/src/lib/tests/sandbox.test.ts new file mode 100644 index 0000000..a818a65 --- /dev/null +++ b/src/lib/tests/sandbox.test.ts @@ -0,0 +1,2735 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest, describe, it, expect } from "@jest/globals"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { inspect } from "node:util"; +import { SandboxClient } from "../sandbox/client.js"; +import { Sandbox } from "../sandbox/sandbox.js"; +import { CommandHandle } from "../sandbox/command_handle.js"; +import { + awsAuth, + contextHubMount, + gitMount, + gcpAuth, + gcsMount, + mountConfig, + opaqueSecret, + proxyConfig, + s3Mount, + workspaceSecret, +} from "../sandbox/index.js"; +import { + buildWsUrl, + buildAuthHeaders, + WSStreamControl, + raiseForWsError, +} from "../sandbox/ws_execute.js"; +import { + LangSmithResourceCreationError, + LangSmithResourceNotFoundError, + LangSmithDataplaneNotConfiguredError, + LangSmithQuotaExceededError, + LangSmithValidationError, + LangSmithResourceTimeoutError, + LangSmithSandboxCreationError, + LangSmithSandboxOperationError, + LangSmithCommandTimeoutError, + LangSmithSandboxServerReloadError, + LangSmithSandboxConnectionError, + LangSmithSandboxNotReadyError, + LangSmithStreamEndedBeforeStartedError, +} from "../sandbox/errors.js"; +import type { + WsMessage, + OutputChunk, + CreateSandboxOptions, +} from "../sandbox/types.js"; +import { validateTtl } from "../sandbox/helpers.js"; + +const assertRawMountsAreNotCreateOptions = ( + options: CreateSandboxOptions, +): void => { + void options; + // @ts-expect-error raw mounts are only accepted inside mountConfig + const invalidOptions: CreateSandboxOptions = { mounts: [] }; + void invalidOptions; +}; +void assertRawMountsAreNotCreateOptions; + +// Helper to create typed mock functions +const createMockFetch = (response: any) => + jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue(response); + +// Helper to create a mock SandboxClient with the required methods +const createMockClient = (overrides: Record = {}) => + ({ + _fetch: createMockFetch({}), + getApiKey: () => "test-key", + getDefaultHeaders: () => ({}), + deleteSandbox: jest.fn<() => Promise>().mockResolvedValue(undefined), + ...overrides, + }) as unknown as SandboxClient; + +// run() uses WebSocket and only falls back to the blocking HTTP endpoint when +// the 'ws' package can't be loaded. Simulate that so the tests below can pin +// the HTTP fallback path's request/response shaping. +const forceHttpFallback = (sandbox: any) => + jest.spyOn(sandbox, "_wsAvailable").mockResolvedValue(false); + +describe("sandbox proxy config helpers", () => { + it("workspaceSecret wraps names and preserves references", () => { + expect(workspaceSecret("AWS_KEY_ID_REF")).toEqual({ + type: "workspace_secret", + value: "{AWS_KEY_ID_REF}", + }); + expect(workspaceSecret("{AWS_KEY_VALUE_REF}")).toEqual({ + type: "workspace_secret", + value: "{AWS_KEY_VALUE_REF}", + }); + }); + + it.each(["", " ", "{}", "{AWS_KEY_ID_REF"])( + "workspaceSecret rejects invalid name %p", + (name) => { + expect(() => workspaceSecret(name)).toThrow(); + }, + ); + + it("opaqueSecret builds a write-only secret value", () => { + expect(opaqueSecret("AKIAFAKE")).toEqual({ + type: "opaque", + value: "AKIAFAKE", + }); + }); + + it("awsAuth builds an AWS auth rule", () => { + expect( + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + ).toEqual({ + name: "aws", + type: "aws", + enabled: true, + aws: { + access_key_id: { + type: "workspace_secret", + value: "{AWS_KEY_ID_REF}", + }, + secret_access_key: { + type: "workspace_secret", + value: "{AWS_KEY_VALUE_REF}", + }, + }, + }); + }); + + it("provider rules carry env vars and omit them when unset", () => { + expect( + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + envVars: { AWS_ACCESS_KEY_ID: "dummy" }, + }).env_vars, + ).toEqual({ AWS_ACCESS_KEY_ID: "dummy" }); + + const gcpRule = gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + envVars: { GOOGLE_API_KEY: "dummy" }, + }); + expect(gcpRule.env_vars).toEqual({ GOOGLE_API_KEY: "dummy" }); + + expect( + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + }), + ).not.toHaveProperty("env_vars"); + }); + + it("preserves surrounding whitespace in env var values", () => { + expect( + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + envVars: { PREFIX: " /opt/bin " }, + }).env_vars, + ).toEqual({ PREFIX: " /opt/bin " }); + }); + + it.each>([{}, { "": "value" }, { NAME: "" }])( + "rejects invalid env vars %j", + (envVars) => { + expect(() => + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + envVars, + }), + ).toThrow(); + }, + ); + + it("gcpAuth builds a GCP auth rule with built-in Google API host matching", () => { + expect( + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], + }), + ).toEqual({ + name: "gcp", + type: "gcp", + enabled: true, + gcp: { + service_account_json: { + type: "workspace_secret", + value: "{GCP_SERVICE_ACCOUNT_JSON}", + }, + scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], + }, + }); + }); + + it("proxyConfig composes multiple provider rules", () => { + const awsRule = awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }); + const gcpRule = gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], + }); + + expect( + proxyConfig({ + rules: [awsRule, gcpRule], + accessControl: { allow_list: ["*.googleapis.com", "*.amazonaws.com"] }, + }), + ).toEqual({ + rules: [awsRule, gcpRule], + access_control: { allow_list: ["*.googleapis.com", "*.amazonaws.com"] }, + }); + }); + + it("proxyConfig ignores the deprecated noProxy option", () => { + expect( + proxyConfig({ rules: [], noProxy: ["metadata.google.internal"] }), + ).toEqual({ rules: [] }); + }); + + it("mountConfig nests mounts and provider auth", () => { + const awsAuthRule = awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }); + const gcpAuthRule = gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + }); + + expect( + mountConfig({ + auth: [awsAuthRule, gcpAuthRule], + mounts: [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + prefix: "datasets", + region: "us-east-1", + readOnly: true, + }), + gcsMount({ + id: "gcs_data", + mountPath: "/mnt/gcs-data", + bucket: "gcs-bucket", + prefix: "datasets", + }), + ], + }), + ).toEqual({ + auth: { + aws: awsAuthRule.aws, + gcp: { + service_account_json: { + type: "workspace_secret", + value: "{GCP_SERVICE_ACCOUNT_JSON}", + }, + }, + }, + mounts: [ + { + id: "s3_data", + type: "s3", + mount_path: "/mnt/s3-data", + read_only: true, + s3: { + endpoint_url: "https://s3.amazonaws.com", + region: "us-east-1", + bucket: "s3-bucket", + prefix: "datasets", + path_style: false, + }, + }, + { + id: "gcs_data", + type: "gcs", + mount_path: "/mnt/gcs-data", + gcs: { + bucket: "gcs-bucket", + prefix: "datasets", + }, + }, + ], + }); + }); + + it("gitMount serializes the backend shape", () => { + expect( + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + ref: { type: "branch", name: "main" }, + refreshIntervalSeconds: 60, + }), + ).toEqual({ + id: "repo", + type: "git", + mount_path: "/mnt/repo", + git: { + remote_url: "https://github.com/langchain-ai/langsmith-sdk.git", + ref: { type: "branch", name: "main" }, + refresh_interval_seconds: 60, + }, + }); + }); + + it("gitMount allows tag refs and omitted optional fields", () => { + expect( + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + ref: { type: "tag", name: "v1.0.0" }, + }).git, + ).toEqual({ + remote_url: "https://github.com/langchain-ai/langsmith-sdk.git", + ref: { type: "tag", name: "v1.0.0" }, + }); + + expect( + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + }).git, + ).toEqual({ + remote_url: "https://github.com/langchain-ai/langsmith-sdk.git", + }); + }); + + it.each([ + "", + "http://github.com/langchain-ai/langsmith-sdk.git", + "https://github.com", + "https://user:pass@github.com/langchain-ai/langsmith-sdk.git", + "https://github.com/langchain-ai/langsmith-sdk.git?token=secret", + "https://github.com/langchain-ai/langsmith-sdk.git#main", + "https://github.com/langchain-ai/langsmith-sdk.git\n", + "https://github.com/langchain-ai/langsmith-sdk.git\0", + ])("gitMount rejects invalid remote URL %p", (remoteUrl) => { + expect(() => + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl, + }), + ).toThrow(); + }); + + it.each([ + { ref: { type: "commit", name: "abc123" } }, + { ref: { type: "branch" } }, + { ref: { type: "branch", name: "" } }, + { refreshIntervalSeconds: 0 }, + ])("gitMount rejects invalid ref or refresh interval", (options) => { + expect(() => + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + ...(options as any), + }), + ).toThrow(); + }); + + it("mountConfig accepts Git mounts without provider auth", () => { + const mount = gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + }); + + expect(mountConfig({ mounts: [mount] })).toEqual({ + auth: {}, + mounts: [mount], + }); + }); + + it("contextHubMount serializes the backend shape", () => { + expect( + contextHubMount({ + id: "memories", + mountPath: "/memories", + repo: "-/my-agent", + initialPullOnly: true, + }), + ).toEqual({ + id: "memories", + type: "contexthub", + mount_path: "/memories", + contexthub: { repo: "-/my-agent", initial_pull_only: true }, + }); + }); + + it("contextHubMount omits optional fields", () => { + expect( + contextHubMount({ + id: "memories", + mountPath: "/memories", + repo: "-/my-agent", + }), + ).toEqual({ + id: "memories", + type: "contexthub", + mount_path: "/memories", + contexthub: { repo: "-/my-agent" }, + }); + }); + + it("mountConfig accepts Context Hub mounts without provider auth", () => { + const mount = contextHubMount({ + id: "memories", + mountPath: "/memories", + repo: "-/my-agent", + }); + + expect(mountConfig({ mounts: [mount] })).toEqual({ + auth: {}, + mounts: [mount], + }); + }); + + it("mountConfig nests mixed bucket and Git mounts", () => { + const awsAuthRule = awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }); + const gcpAuthRule = gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + }); + const mounts = [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + }), + gcsMount({ + id: "gcs_data", + mountPath: "/mnt/gcs-data", + bucket: "gcs-bucket", + }), + gitMount({ + id: "repo", + mountPath: "/mnt/repo", + remoteUrl: "https://github.com/langchain-ai/langsmith-sdk.git", + }), + ]; + + expect(mountConfig({ auth: [awsAuthRule, gcpAuthRule], mounts })).toEqual({ + auth: { + aws: awsAuthRule.aws, + gcp: { + service_account_json: { + type: "workspace_secret", + value: "{GCP_SERVICE_ACCOUNT_JSON}", + }, + }, + }, + mounts, + }); + }); + + it.each([ + { + auth: [], + mounts: [ + s3Mount({ id: "s3_data", mountPath: "/mnt/s3-data", bucket: "b" }), + ], + message: /aws/i, + }, + { + auth: [], + mounts: [ + gcsMount({ id: "gcs_data", mountPath: "/mnt/gcs-data", bucket: "b" }), + ], + message: /gcp/i, + }, + { + auth: [ + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF_2"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF_2"), + }), + ], + mounts: [ + s3Mount({ id: "s3_data", mountPath: "/mnt/s3-data", bucket: "b" }), + ], + message: /duplicate/i, + }, + ])("mountConfig validates provider auth", ({ auth, mounts, message }) => { + expect(() => mountConfig({ auth, mounts })).toThrow(message); + }); + + it("mountConfig rejects provider credentials inside mount specs", () => { + const mount = s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + }) as any; + mount.s3.access_key_id = workspaceSecret("AWS_KEY_ID_REF"); + + expect(() => + mountConfig({ + auth: [ + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + ], + mounts: [mount], + }), + ).toThrow(/credentials/i); + }); + + it.each([{ scopes: [] }, { scopes: [""] }])( + "gcpAuth rejects empty scopes", + ({ scopes }) => { + expect(() => + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + scopes, + }), + ).toThrow(); + }, + ); + + it("proxyConfig rejects GCP auth without scopes", () => { + expect(() => + proxyConfig({ + rules: [ + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + }), + ], + }), + ).toThrow(/scopes/i); + }); +}); + +describe("SandboxClient", () => { + describe("constructor", () => { + it("should trim trailing slash from endpoint", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes/", + apiKey: "test-key", + }); + expect((client as any)._baseUrl).toBe("https://custom.api.com/sandboxes"); + }); + + it("should use custom endpoint when provided", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "test-key", + }); + expect((client as any)._baseUrl).toBe("https://custom.api.com/sandboxes"); + }); + + it("should expose constructor headers via getDefaultHeaders()", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "test-key", + headers: { "X-Service-Key": "svc-jwt" }, + }); + expect(client.getDefaultHeaders()).toEqual({ + "X-Service-Key": "svc-jwt", + }); + expect(client.getApiKey()).toBe("test-key"); + }); + + it("should default to empty default headers", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "test-key", + }); + expect(client.getDefaultHeaders()).toEqual({}); + }); + }); + + describe("toString", () => { + it("should not expose sensitive information like API keys", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "super-secret-sandbox-api-key-12345", + }); + + const str = client.toString(); + expect(str).not.toContain("super-secret-sandbox-api-key-12345"); + expect(str).toContain("https://custom.api.com/sandboxes"); + expect(str).toBe( + '[LangSmithSandboxClient apiEndpoint="https://custom.api.com/sandboxes"]', + ); + }); + + it("should be called when converting to string", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "secret-key", + }); + + const str = String(client); + expect(str).not.toContain("secret-key"); + expect(str).toContain("https://custom.api.com/sandboxes"); + }); + + it("should be called by Node.js inspect", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "secret-key", + }); + + const inspectResult = inspect(client); + expect(inspectResult).not.toContain("secret-key"); + expect(inspectResult).toContain("https://custom.api.com/sandboxes"); + expect(inspectResult).toBe( + '[LangSmithSandboxClient apiEndpoint="https://custom.api.com/sandboxes"]', + ); + }); + + it("should expose the Node.js custom inspect hook", () => { + const client = new SandboxClient({ + apiEndpoint: "https://custom.api.com/sandboxes", + apiKey: "secret-key", + }); + + const inspectSymbol = Symbol.for("nodejs.util.inspect.custom"); + const inspectFn = (client as any)[inspectSymbol]; + expect(typeof inspectFn).toBe("function"); + expect(inspectFn.call(client)).toBe( + '[LangSmithSandboxClient apiEndpoint="https://custom.api.com/sandboxes"]', + ); + }); + }); +}); + +describe("Sandbox", () => { + describe("run", () => { + it("should throw DataplaneNotConfiguredError when dataplane_url is missing", async () => { + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + // No dataplane_url + }, + createMockClient(), + false, + ); + + await expect(sandbox.run("echo hello")).rejects.toThrow( + LangSmithDataplaneNotConfiguredError, + ); + }); + + it("should execute a command and return result", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({ + stdout: "Hello, World!\n", + stderr: "", + exit_code: 0, + }), + }); + + const mockClient = createMockClient({ _fetch: mockFetch }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + false, + ); + forceHttpFallback(sandbox); + + const result = await sandbox.run('echo "Hello, World!"'); + + expect(result.stdout).toBe("Hello, World!\n"); + expect(result.stderr).toBe(""); + expect(result.exit_code).toBe(0); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should pass environment variables and cwd to command", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({ + stdout: "test-value\n", + stderr: "", + exit_code: 0, + }), + }); + + const mockClient = createMockClient({ _fetch: mockFetch }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + false, + ); + forceHttpFallback(sandbox); + + await sandbox.run("echo $MY_VAR", { + env: { MY_VAR: "test-value" }, + cwd: "/tmp", + }); + + const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(options.body as string); + expect(body.env).toEqual({ MY_VAR: "test-value" }); + expect(body.cwd).toBe("/tmp"); + }); + + it("propagates a WebSocket error without falling back to HTTP", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({ stdout: "", stderr: "", exit_code: 0 }), + }); + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + createMockClient({ _fetch: mockFetch }), + false, + ); + jest.spyOn(sandbox as any, "_wsAvailable").mockResolvedValue(true); + jest + .spyOn(sandbox as any, "_runWs") + .mockRejectedValue(new LangSmithSandboxConnectionError("WS failed")); + + await expect(sandbox.run("echo hello")).rejects.toThrow( + LangSmithSandboxConnectionError, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe("write", () => { + it("should write string content to a file", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({}), + }); + + const mockClient = createMockClient({ _fetch: mockFetch }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + false, + ); + + await sandbox.write("/tmp/test.txt", "Hello, World!"); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/upload?path=%2Ftmp%2Ftest.txt"); + }); + + it("should write Uint8Array content", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({}), + }); + + const mockClient = createMockClient({ _fetch: mockFetch }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + false, + ); + + const content = new TextEncoder().encode("Binary content"); + await sandbox.write("/tmp/test.bin", content); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("read", () => { + it("should read content from a file", async () => { + const testContent = "File content here"; + const mockFetch = createMockFetch({ + ok: true, + arrayBuffer: async () => new TextEncoder().encode(testContent).buffer, + }); + + const mockClient = createMockClient({ _fetch: mockFetch }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + false, + ); + + const content = await sandbox.read("/tmp/test.txt"); + + const text = new TextDecoder().decode(content); + expect(text).toBe("File content here"); + }); + }); + + describe("delete", () => { + it("should call deleteSandbox on the client", async () => { + const mockDeleteSandbox = jest + .fn<(name: string) => Promise>() + .mockResolvedValue(undefined); + + const mockClient = createMockClient({ + deleteSandbox: mockDeleteSandbox, + }); + + const sandbox = new (Sandbox as any)( + { + id: "sandbox-123", + name: "test-sandbox", + dataplane_url: "https://dataplane.example.com", + }, + mockClient, + ); + + await sandbox.delete(); + + expect(mockDeleteSandbox).toHaveBeenCalledWith("test-sandbox"); + }); + }); +}); + +describe("SandboxClient - createSandbox", () => { + // Helper to create a SandboxClient with a mocked fetch + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should send wait_for_ready: true and include timeout by default", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + dataplane_url: "https://dp.example.com", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.createSandbox("snap-123"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.wait_for_ready).toBe(true); + expect(body.timeout).toBe(30); + }); + + it("should send wait_for_ready: false and omit timeout when waitForReady is false", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "provisioning", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.createSandbox("snap-123", { + waitForReady: false, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.wait_for_ready).toBe(false); + expect(body.timeout).toBeUndefined(); + expect(sandbox.status).toBe("provisioning"); + }); + + it("should use 30s HTTP timeout when waitForReady is false", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "provisioning", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.createSandbox("snap-123", { waitForReady: false }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + // The signal should be an AbortSignal with 30s timeout + expect(init.signal).toBeDefined(); + }); + + it("should include idle_ttl_seconds and delete_after_stop_seconds in the request body when set", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + dataplane_url: "https://dp.example.com", + status: "ready", + idle_ttl_seconds: 600, + delete_after_stop_seconds: 86400, + stopped_at: null, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.createSandbox("snap-123", { + idleTtlSeconds: 600, + deleteAfterStopSeconds: 86400, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.idle_ttl_seconds).toBe(600); + expect(body.delete_after_stop_seconds).toBe(86400); + expect(sandbox.idle_ttl_seconds).toBe(600); + expect(sandbox.delete_after_stop_seconds).toBe(86400); + expect(sandbox.stopped_at).toBeUndefined(); + }); + + it("should reject invalid retention values before calling the API", async () => { + const mockFetch = jest.fn(); + const client = createClientWithMock(mockFetch); + + await expect( + client.createSandbox("snap-123", { idleTtlSeconds: 61 }), + ).rejects.toThrow(LangSmithValidationError); + await expect( + client.createSandbox("snap-123", { deleteAfterStopSeconds: 61 }), + ).rejects.toThrow(LangSmithValidationError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should forward proxyConfig in the request body under proxy_config", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const proxyConfig = { + access_control: { + allow_list: ["github.com", "*.example.com"], + }, + }; + await client.createSandbox("snap-123", { proxyConfig }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.proxy_config).toEqual(proxyConfig); + }); + + it("should forward composed proxy config in the request body", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const config = proxyConfig({ + rules: [ + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + ], + }); + await client.createSandbox("snap-123", { proxyConfig: config }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.proxy_config).toEqual(config); + }); + + it("should forward mountConfig in the request body under mount_config", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const config = mountConfig({ + auth: [ + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + ], + mounts: [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + }), + ], + }); + await client.createSandbox("snap-123", { mountConfig: config }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.mount_config).toEqual(config); + expect(body.mounts).toBeUndefined(); + expect(body.proxy_config).toBeUndefined(); + }); + + it("should preserve mountConfig and proxyConfig separately", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const awsAuthBlock = awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }); + const extraRule = { + name: "github", + type: "headers", + enabled: true, + match_hosts: ["github.com"], + headers: { authorization: "Bearer {GITHUB_TOKEN}" }, + }; + const config = mountConfig({ + auth: [awsAuthBlock], + mounts: [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + }), + ], + }); + const extraProxyConfig = proxyConfig({ + rules: [extraRule], + accessControl: { allow_list: ["github.com", "*.amazonaws.com"] }, + }); + + await client.createSandbox("snap-123", { + mountConfig: config, + proxyConfig: extraProxyConfig, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.mount_config).toEqual(config); + expect(body.mounts).toBeUndefined(); + expect(body.proxy_config).toEqual({ + rules: [extraRule], + access_control: { allow_list: ["github.com", "*.amazonaws.com"] }, + }); + }); + + it.each([ + { + provider: "aws", + mountAuth: () => + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + mounts: () => [ + s3Mount({ + id: "s3_data", + mountPath: "/mnt/s3-data", + bucket: "s3-bucket", + }), + ], + explicitAuth: () => + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF_2"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF_2"), + name: "aws-extra", + }), + message: + "aws auth cannot be provided in both mountConfig and proxyConfig", + }, + { + provider: "gcp", + mountAuth: () => + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON"), + }), + mounts: () => [ + gcsMount({ + id: "gcs_data", + mountPath: "/mnt/gcs-data", + bucket: "gcs-bucket", + }), + ], + explicitAuth: () => + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT_JSON_2"), + scopes: ["https://www.googleapis.com/auth/devstorage.read_write"], + name: "gcp-extra", + }), + message: + "gcp auth cannot be provided in both mountConfig and proxyConfig", + }, + ])( + "should reject duplicate $provider auth across mountConfig and proxyConfig", + async ({ mountAuth, mounts, explicitAuth, message }) => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + const client = createClientWithMock(mockFetch); + const config = mountConfig({ + auth: [mountAuth()], + mounts: mounts(), + }); + const extraProxyConfig = proxyConfig({ + rules: [explicitAuth()], + }); + + await expect( + client.createSandbox("snap-123", { + mountConfig: config, + proxyConfig: extraProxyConfig, + }), + ).rejects.toThrow(message); + expect(mockFetch).not.toHaveBeenCalled(); + }, + ); + + it("should omit proxy_config from the request body when not provided", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.createSandbox("snap-123"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.proxy_config).toBeUndefined(); + }); +}); + +describe("validateTtl", () => { + it("accepts undefined, 0, and positive multiples of 60", () => { + expect(() => validateTtl(undefined, "idleTtlSeconds")).not.toThrow(); + expect(() => validateTtl(0, "idleTtlSeconds")).not.toThrow(); + expect(() => validateTtl(60, "idleTtlSeconds")).not.toThrow(); + expect(() => validateTtl(3600, "deleteAfterStopSeconds")).not.toThrow(); + }); + + it("rejects negative values and non-multiples of 60", () => { + expect(() => validateTtl(-1, "idleTtlSeconds")).toThrow( + LangSmithValidationError, + ); + expect(() => validateTtl(30, "idleTtlSeconds")).toThrow( + LangSmithValidationError, + ); + expect(() => validateTtl(61, "deleteAfterStopSeconds")).toThrow( + LangSmithValidationError, + ); + }); +}); + +describe("SandboxClient - updateSandbox", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should PATCH retention fields when provided in options", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "sb-1", + status: "ready", + idle_ttl_seconds: 1800, + delete_after_stop_seconds: 0, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.updateSandbox("sb-1", { + idleTtlSeconds: 1800, + deleteAfterStopSeconds: 0, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("PATCH"); + const body = JSON.parse(init.body as string); + expect(body.idle_ttl_seconds).toBe(1800); + expect(body.delete_after_stop_seconds).toBe(0); + expect(body.name).toBeUndefined(); + }); + + it("should still support rename via string second argument", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "sb-renamed", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.updateSandbox("sb-1", "sb-renamed"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.name).toBe("sb-renamed"); + }); + + it("should GET sandbox when update options object is empty", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "sb-1", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sb = await client.updateSandbox("sb-1", {}); + + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/boxes/sb-1"); + expect(init.method).toBeUndefined(); + expect(sb.name).toBe("sb-1"); + }); + + it("should PATCH proxy_config when provided in options", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "sb-1", + status: "ready", + }), + } as Response); + + const config = proxyConfig({ + rules: [ + { + name: "github", + match_hosts: ["github.com"], + headers: [ + { name: "Authorization", type: "opaque", value: "Basic rotated" }, + ], + }, + ], + }); + + const client = createClientWithMock(mockFetch); + await client.updateSandbox("sb-1", { proxyConfig: config }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ proxy_config: config }); + }); + + it("should throw NotReady when proxy config is set on a stopped sandbox", async () => { + const body = { + detail: { + error: "InvalidRequest", + message: + 'sandbox "sb-1" is in "stopped" state, must be "ready" to update proxy config', + }, + }; + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + json: async () => body, + clone: () => ({ status: 400, json: async () => body }), + } as unknown as Response); + + const client = createClientWithMock(mockFetch); + await expect( + client.updateSandbox("sb-1", { proxyConfig: { rules: [] } }), + ).rejects.toThrow(LangSmithSandboxNotReadyError); + }); +}); + +describe("SandboxClient - getSandboxStatus", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should return ResourceStatus", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const result = await client.getSandboxStatus("test-sb"); + + expect(result.status).toBe("ready"); + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/boxes/test-sb/status"); + }); + + it("should throw LangSmithResourceNotFoundError on 404", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + } as Response); + + const client = createClientWithMock(mockFetch); + await expect(client.getSandboxStatus("nonexistent")).rejects.toThrow( + LangSmithResourceNotFoundError, + ); + }); +}); + +describe("SandboxClient - waitForSandbox", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should poll until ready and return sandbox", async () => { + let callCount = 0; + const mockFetch = jest + .fn() + .mockImplementation(async (url: any) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr.includes("/status")) { + callCount++; + return { + ok: true, + json: async () => ({ + status: callCount < 3 ? "provisioning" : "ready", + }), + } as Response; + } + // getSandbox call + return { + ok: true, + json: async () => ({ + name: "test-sb", + dataplane_url: "https://dp.example.com", + status: "ready", + }), + } as Response; + }); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.waitForSandbox("test-sb", { + pollInterval: 0.01, + }); + + expect(sandbox.status).toBe("ready"); + expect(sandbox.dataplane_url).toBe("https://dp.example.com"); + expect(callCount).toBe(3); + }); + + it("should throw LangSmithResourceCreationError on failed status", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "failed", + status_message: "Image pull failed", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await expect( + client.waitForSandbox("test-sb", { pollInterval: 0.01 }), + ).rejects.toThrow(LangSmithResourceCreationError); + }); + + it("should throw LangSmithResourceTimeoutError on timeout", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + status: "provisioning", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await expect( + client.waitForSandbox("test-sb", { timeout: 0.05, pollInterval: 0.01 }), + ).rejects.toThrow(LangSmithResourceTimeoutError); + }); +}); + +describe("Sandbox - status fields and not-ready guard", () => { + it("should populate status and status_message from SandboxData", () => { + const sandbox = new (Sandbox as any)( + { + name: "test-sandbox", + status: "provisioning", + status_message: "Waiting for resources", + }, + createMockClient(), + ); + + expect(sandbox.status).toBe("provisioning"); + expect(sandbox.status_message).toBe("Waiting for resources"); + }); + + it("does not gate dataplane ops on status (stopped runs; platform resumes)", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({ stdout: "ok\n", stderr: "", exit_code: 0 }), + }); + const sandbox = new (Sandbox as any)( + { + name: "test-sandbox", + dataplane_url: "https://dp.example.com", + status: "stopped", + }, + createMockClient({ _fetch: mockFetch }), + ); + forceHttpFallback(sandbox); + + const result = await sandbox.run("echo ok"); + expect(result.stdout).toBe("ok\n"); + }); + + it("should allow operations when status is ready", async () => { + const mockFetch = createMockFetch({ + ok: true, + json: async () => ({ + stdout: "hello\n", + stderr: "", + exit_code: 0, + }), + }); + + const sandbox = new (Sandbox as any)( + { + name: "test-sandbox", + dataplane_url: "https://dp.example.com", + status: "ready", + }, + createMockClient({ _fetch: mockFetch }), + ); + forceHttpFallback(sandbox); + + const result = await sandbox.run("echo hello"); + expect(result.stdout).toBe("hello\n"); + }); +}); + +describe("Error classes", () => { + it("LangSmithResourceNotFoundError should have resourceType", () => { + const error = new LangSmithResourceNotFoundError("Not found", "sandbox"); + expect(error.resourceType).toBe("sandbox"); + expect(error.message).toBe("Not found"); + expect(error.name).toBe("LangSmithResourceNotFoundError"); + }); + + it("LangSmithResourceTimeoutError should have resourceType and lastStatus", () => { + const error = new LangSmithResourceTimeoutError( + "Timeout", + "sandbox", + "pending", + ); + expect(error.resourceType).toBe("sandbox"); + expect(error.lastStatus).toBe("pending"); + expect(error.toString()).toContain("pending"); + }); + + it("LangSmithQuotaExceededError should have quotaType", () => { + const error = new LangSmithQuotaExceededError( + "Quota exceeded", + "sandbox_count", + ); + expect(error.quotaType).toBe("sandbox_count"); + }); + + it("LangSmithValidationError should have field and details", () => { + const details = [ + { loc: ["body", "cpu"], msg: "Invalid", type: "value_error" }, + ]; + const error = new LangSmithValidationError( + "Validation failed", + "cpu", + details, + ); + expect(error.field).toBe("cpu"); + expect(error.details).toEqual(details); + }); + + it("LangSmithSandboxCreationError should have errorType and custom toString", () => { + const error = new LangSmithSandboxCreationError( + "Creation failed", + "ImagePull", + ); + expect(error.errorType).toBe("ImagePull"); + expect(error.toString()).toContain("ImagePull"); + }); + + it("LangSmithDataplaneNotConfiguredError should be a LangSmithSandboxError", () => { + const error = new LangSmithDataplaneNotConfiguredError("No dataplane"); + expect(error.name).toBe("LangSmithDataplaneNotConfiguredError"); + }); + + it("LangSmithResourceCreationError should have resourceType and errorType", () => { + const error = new LangSmithResourceCreationError( + "Provisioning failed", + "sandbox", + "ImagePull", + ); + expect(error.name).toBe("LangSmithResourceCreationError"); + expect(error.resourceType).toBe("sandbox"); + expect(error.errorType).toBe("ImagePull"); + expect(error.toString()).toContain("ImagePull"); + }); + + it("LangSmithCommandTimeoutError should extend SandboxOperationError", () => { + const error = new LangSmithCommandTimeoutError("Command timed out", 60); + expect(error.name).toBe("LangSmithCommandTimeoutError"); + expect(error.timeout).toBe(60); + expect(error.operation).toBe("command"); + expect(error.errorType).toBe("CommandTimeout"); + expect(error).toBeInstanceOf(LangSmithSandboxOperationError); + }); + + it("LangSmithSandboxServerReloadError should extend SandboxConnectionError", () => { + const error = new LangSmithSandboxServerReloadError("Server reloading"); + expect(error.name).toBe("LangSmithSandboxServerReloadError"); + expect(error.message).toBe("Server reloading"); + }); +}); + +// ============================================================================= +// WebSocket Execute Tests +// ============================================================================= + +describe("buildWsUrl", () => { + it("should convert https to wss and append /execute/ws", () => { + expect(buildWsUrl("https://dataplane.example.com")).toBe( + "wss://dataplane.example.com/execute/ws", + ); + }); + + it("should convert http to ws", () => { + expect(buildWsUrl("http://localhost:8080")).toBe( + "ws://localhost:8080/execute/ws", + ); + }); + + it("should handle URLs with paths", () => { + expect(buildWsUrl("https://dataplane.example.com/some/path")).toBe( + "wss://dataplane.example.com/some/path/execute/ws", + ); + }); +}); + +describe("buildAuthHeaders", () => { + it("should return X-Api-Key header when key is provided", () => { + expect(buildAuthHeaders("test-key")).toEqual({ "X-Api-Key": "test-key" }); + }); + + it("should return empty headers when no key", () => { + expect(buildAuthHeaders(undefined)).toEqual({}); + }); + + it("should return extra headers when provided", () => { + expect(buildAuthHeaders(undefined, { "X-Service-Key": "svc-jwt" })).toEqual( + { + "X-Service-Key": "svc-jwt", + }, + ); + }); + + it("should merge X-Api-Key with extra headers", () => { + expect(buildAuthHeaders("api-key", { "X-Service-Key": "svc-jwt" })).toEqual( + { + "X-Api-Key": "api-key", + "X-Service-Key": "svc-jwt", + }, + ); + }); +}); + +describe("WSStreamControl", () => { + it("should track killed state", () => { + const control = new WSStreamControl(); + expect(control.killed).toBe(false); + control.sendKill(); + expect(control.killed).toBe(true); + }); + + it("should not throw when sending on unbound control", () => { + const control = new WSStreamControl(); + // Should not throw + control.sendKill(); + control.sendInput("hello"); + }); +}); + +describe("raiseForWsError", () => { + it("should throw LangSmithCommandTimeoutError for CommandTimeout", () => { + const msg: WsMessage = { + type: "error", + error_type: "CommandTimeout", + error: "Command timed out after 60s", + }; + expect(() => raiseForWsError(msg)).toThrow(LangSmithCommandTimeoutError); + }); + + it("should throw SandboxOperationError for CommandNotFound", () => { + const msg: WsMessage = { + type: "error", + error_type: "CommandNotFound", + error: "Not found", + }; + expect(() => raiseForWsError(msg, "cmd-123")).toThrow( + LangSmithSandboxOperationError, + ); + try { + raiseForWsError(msg, "cmd-123"); + } catch (e: any) { + expect(e.message).toContain("cmd-123"); + expect(e.operation).toBe("reconnect"); + } + }); + + it("should throw SandboxOperationError for SessionExpired", () => { + const msg: WsMessage = { + type: "error", + error_type: "SessionExpired", + error: "Expired", + }; + expect(() => raiseForWsError(msg)).toThrow(LangSmithSandboxOperationError); + }); + + it("should throw SandboxOperationError for unknown error types", () => { + const msg: WsMessage = { + type: "error", + error_type: "UnknownError", + error: "Something went wrong", + }; + expect(() => raiseForWsError(msg)).toThrow(LangSmithSandboxOperationError); + }); +}); + +// ============================================================================= +// CommandHandle Tests +// ============================================================================= + +describe("CommandHandle", () => { + // Helper to create an async iterator from an array of WsMessages + function createMockStream( + messages: WsMessage[], + ): AsyncIterableIterator { + let index = 0; + return { + next: async () => { + if (index < messages.length) { + return { value: messages[index++], done: false }; + } + return { value: undefined as any, done: true }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + function createMockSandbox(): Sandbox { + return { + _client: { getApiKey: () => "test-key" }, + dataplane_url: "https://dp.example.com", + name: "test-sandbox", + reconnect: jest.fn(), + } as unknown as Sandbox; + } + + describe("construction and _ensureStarted", () => { + it("should read 'started' message and populate commandId/pid", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "hello\n", offset: 0 }, + { type: "exit", exit_code: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await handle._ensureStarted(); + + expect(handle.commandId).toBe("cmd-123"); + expect(handle.pid).toBe(42); + }); + + it("should throw if stream ends before started message", async () => { + const stream = createMockStream([]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await expect(handle._ensureStarted()).rejects.toThrow( + LangSmithSandboxOperationError, + ); + }); + + it("should throw if first message is not 'started'", async () => { + const stream = createMockStream([ + { type: "stdout", data: "hello\n", offset: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await expect(handle._ensureStarted()).rejects.toThrow( + "Expected 'started' message", + ); + }); + + it("should skip _ensureStarted for reconnections (commandId set)", async () => { + const stream = createMockStream([ + { type: "stdout", data: "hello\n", offset: 0 }, + { type: "exit", exit_code: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox(), { + commandId: "cmd-123", + }); + // Should already be started + expect(handle.commandId).toBe("cmd-123"); + }); + + it("throws the early-close marker when the stream ends before started", async () => { + const stream = createMockStream([]); + const handle = new CommandHandle(stream, null, createMockSandbox()); + await expect(handle._ensureStarted()).rejects.toThrow( + LangSmithStreamEndedBeforeStartedError, + ); + }); + }); + + describe("iteration", () => { + it("should yield OutputChunk objects", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "hello ", offset: 0 }, + { type: "stderr", data: "warn\n", offset: 0 }, + { type: "stdout", data: "world\n", offset: 6 }, + { type: "exit", exit_code: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await handle._ensureStarted(); + + const chunks: OutputChunk[] = []; + for await (const chunk of handle) { + chunks.push(chunk); + } + + expect(chunks).toHaveLength(3); + expect(chunks[0]).toEqual({ + stream: "stdout", + data: "hello ", + offset: 0, + }); + expect(chunks[1]).toEqual({ + stream: "stderr", + data: "warn\n", + offset: 0, + }); + expect(chunks[2]).toEqual({ + stream: "stdout", + data: "world\n", + offset: 6, + }); + }); + + it("should track offsets", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "hello", offset: 0 }, + { type: "stderr", data: "err", offset: 0 }, + { type: "exit", exit_code: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await handle._ensureStarted(); + + for await (const _chunk of handle) { + // drain + } + + expect(handle.lastStdoutOffset).toBe(5); + expect(handle.lastStderrOffset).toBe(3); + }); + }); + + describe("result", () => { + it("should drain stream and return ExecutionResult", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "hello ", offset: 0 }, + { type: "stdout", data: "world\n", offset: 6 }, + { type: "stderr", data: "warning\n", offset: 0 }, + { type: "exit", exit_code: 0 }, + ]); + + const handle = new CommandHandle(stream, null, createMockSandbox()); + await handle._ensureStarted(); + + const result = await handle.result; + + expect(result.stdout).toBe("hello world\n"); + expect(result.stderr).toBe("warning\n"); + expect(result.exit_code).toBe(0); + }); + + it("should reconnect if stream ends without exit message", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "hello\n", offset: 0 }, + ]); + + const sandbox = createMockSandbox(); + const reconnectHandle = { + _stream: createMockStream([{ type: "exit", exit_code: 0 }]), + _control: null, + }; + (sandbox.reconnect as any).mockResolvedValue(reconnectHandle); + + const handle = new CommandHandle(stream, null, sandbox); + await handle._ensureStarted(); + const backoffBase = CommandHandle.BACKOFF_BASE; + CommandHandle.BACKOFF_BASE = 0; + try { + const result = await handle.result; + + expect(result.stdout).toBe("hello\n"); + expect(result.exit_code).toBe(0); + expect(sandbox.reconnect).toHaveBeenCalledWith("cmd-123", { + stdoutOffset: 6, + stderrOffset: 0, + }); + } finally { + CommandHandle.BACKOFF_BASE = backoffBase; + } + }); + }); + + describe("output callbacks", () => { + it("should invoke onStdout/onStderr for every chunk", async () => { + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "out1", offset: 0 }, + { type: "stderr", data: "err1", offset: 0 }, + { type: "stdout", data: "out2", offset: 4 }, + { type: "exit", exit_code: 0 }, + ]); + + const stdoutData: string[] = []; + const stderrData: string[] = []; + const handle = new CommandHandle(stream, null, createMockSandbox(), { + onStdout: (d) => stdoutData.push(d), + onStderr: (d) => stderrData.push(d), + }); + + const result = await handle.result; + + expect(result.exit_code).toBe(0); + expect(stdoutData).toEqual(["out1", "out2"]); + expect(stderrData).toEqual(["err1"]); + }); + + it("should keep invoking callbacks for chunks received after a reconnect", async () => { + // Mid-stream disconnect: the first connection delivers part of the + // output and dies without an exit message; the reconnected stream + // delivers the tail. Callbacks must see ALL chunks (this is the bug + // that silently truncated tails for sandbox.run({ onStdout })). + const stream = createMockStream([ + { type: "started", command_id: "cmd-123", pid: 42 }, + { type: "stdout", data: "before-disconnect ", offset: 0 }, + ]); + + const sandbox = createMockSandbox(); + const reconnectHandle = { + _stream: createMockStream([ + { type: "stdout", data: "after-reconnect", offset: 18 }, + { type: "exit", exit_code: 0 }, + ]), + _control: null, + }; + (sandbox.reconnect as any).mockResolvedValue(reconnectHandle); + + const stdoutData: string[] = []; + const handle = new CommandHandle(stream, null, sandbox, { + onStdout: (d) => stdoutData.push(d), + }); + + const backoffBase = CommandHandle.BACKOFF_BASE; + CommandHandle.BACKOFF_BASE = 0; + try { + const result = await handle.result; + + expect(result.exit_code).toBe(0); + expect(stdoutData).toEqual(["before-disconnect ", "after-reconnect"]); + expect(result.stdout).toBe("before-disconnect after-reconnect"); + } finally { + CommandHandle.BACKOFF_BASE = backoffBase; + } + }); + }); + + describe("kill", () => { + it("should call sendKill on control", () => { + const control = new WSStreamControl(); + const stream = createMockStream([]); + const handle = new CommandHandle(stream, control, createMockSandbox(), { + commandId: "cmd-123", + }); + + handle.kill(); + + expect(control.killed).toBe(true); + }); + }); + + describe("sendInput", () => { + it("should not throw when control is null", () => { + const stream = createMockStream([]); + const handle = new CommandHandle(stream, null, createMockSandbox(), { + commandId: "cmd-123", + }); + + // Should not throw + handle.sendInput("test input"); + }); + }); +}); + +describe("SandboxClient - createSandbox (snapshotId)", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should send snapshot_id in the request body", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + snapshot_id: "snap-1", + dataplane_url: "https://dp.example.com", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.createSandbox("snap-1"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.snapshot_id).toBe("snap-1"); + expect(body.template_name).toBeUndefined(); + expect(sandbox.snapshot_id).toBe("snap-1"); + }); + + it("should include vcpus, mem_bytes, fs_capacity_bytes when provided", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + snapshot_id: "snap-1", + status: "ready", + vcpus: 4, + mem_bytes: 1073741824, + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.createSandbox("snap-1", { + vCpus: 4, + memBytes: 1073741824, + fsCapacityBytes: 4294967296, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.vcpus).toBe(4); + expect(body.mem_bytes).toBe(1073741824); + expect(body.fs_capacity_bytes).toBe(4294967296); + expect(sandbox.vCpus).toBe(4); + expect(sandbox.mem_bytes).toBe(1073741824); + }); + + it("should send snapshot_name (and no snapshot_id) when resolved by name", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + snapshot_id: "snap-1", + dataplane_url: "https://dp.example.com", + status: "ready", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.createSandbox({ + snapshotName: "my-snap", + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.snapshot_name).toBe("my-snap"); + expect(body.snapshot_id).toBeUndefined(); + expect(body.template_name).toBeUndefined(); + expect(sandbox.snapshot_id).toBe("snap-1"); + }); + + it("should omit snapshot_id when no snapshot is provided", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + name: "test-sb", + dataplane_url: "https://dp.example.com", + status: "ready", + }), + } as Response); + const client = createClientWithMock(mockFetch); + + await client.createSandbox({ name: "test-sb" }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.snapshot_id).toBeUndefined(); + expect(body.snapshot_name).toBeUndefined(); + }); + + it("should throw when both snapshotId and snapshotName are provided", async () => { + const mockFetch = jest.fn(); + const client = createClientWithMock(mockFetch); + + await expect( + client.createSandbox("snap-1", { snapshotName: "my-snap" }), + ).rejects.toThrow(LangSmithValidationError); + await expect( + client.createSandbox("snap-1", { snapshotName: "my-snap" }), + ).rejects.toThrow( + /At most one of snapshotId or options\.snapshotName may be set/, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("SandboxClient - snapshot operations", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("createSnapshot should POST and poll until ready", async () => { + const mockFetch = jest + .fn() + // POST /snapshots -> building + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "snap-1", + name: "my-env", + status: "building", + fs_capacity_bytes: 4294967296, + }), + } as Response) + // GET /snapshots/snap-1 -> ready + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "snap-1", + name: "my-env", + status: "ready", + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshot = await client.createSnapshot( + "my-env", + "python:3.12-slim", + 4294967296, + { registryId: "reg-1" }, + ); + + expect(snapshot.id).toBe("snap-1"); + expect(snapshot.status).toBe("ready"); + expect(mockFetch).toHaveBeenCalledTimes(2); + expect( + JSON.parse((mockFetch.mock.calls[0][1] as RequestInit).body as string), + ).toEqual({ + name: "my-env", + docker_image: "python:3.12-slim", + fs_capacity_bytes: 4294967296, + registry_id: "reg-1", + }); + }); + + it("createSnapshotFromDockerfile should sync, build, and capture", async () => { + const context = await mkdtemp(join(tmpdir(), "langsmith-docker-context-")); + const client = createClientWithMock(jest.fn()); + const writes: [string, string | Uint8Array][] = []; + const commands: string[] = []; + const fakeSandbox = { + name: "builder", + write: jest + .fn<(path: string, content: string | Uint8Array) => Promise>() + .mockImplementation(async (path, content) => { + writes.push([path, content]); + }), + run: jest + .fn< + ( + command: string, + ) => Promise<{ stdout: string; stderr: string; exit_code: number }> + >() + .mockImplementation(async (command) => { + commands.push(command); + return { stdout: "", stderr: "", exit_code: 0 }; + }), + delete: jest.fn<() => Promise>().mockResolvedValue(undefined), + }; + const mockSnapshot = { + id: "snap-1", + name: "snap", + status: "ready", + fs_capacity_bytes: 4294967296, + }; + const createSandboxSpy = jest + .spyOn(client, "createSandbox") + .mockResolvedValue(fakeSandbox as any); + const captureSnapshotSpy = jest + .spyOn(client, "captureSnapshot") + .mockResolvedValue(mockSnapshot); + + try { + await writeFile(join(context, "Dockerfile"), "FROM scratch\n"); + + const snapshot = await client.createSnapshotFromDockerfile( + "snap", + "Dockerfile", + 4294967296, + { context }, + ); + + expect(snapshot).toEqual(mockSnapshot); + expect(createSandboxSpy).toHaveBeenCalledWith( + expect.objectContaining({ + name: expect.stringMatching(/^snapshot-builder-/), + fsCapacityBytes: 4294967296, + }), + ); + // Build scratch must live on the capacity-backed root filesystem, not + // the RAM-backed /tmp tmpfs that fsCapacityBytes does not size. + const tarPath = writes[0][0]; + expect(tarPath).toMatch( + /^\/var\/lib\/langsmith-build\/[^/]+\/context\.tar$/, + ); + expect(writes[0][1]).toEqual(expect.any(Uint8Array)); + expect(commands[0]).toContain("tar -xf"); + expect(commands[0]).toContain(tarPath); + expect(commands[0]).not.toContain("/tmp"); + expect(commands[1]).not.toContain("/tmp"); + expect(commands[1]).toContain("--frontend"); + expect(commands[1]).toContain("dockerfile.v0"); + expect(commands[1]).toContain("docker info >/dev/null 2>&1"); + expect(commands[1]).toContain("| docker load"); + expect(captureSnapshotSpy).toHaveBeenCalledWith( + "builder", + "snap", + expect.objectContaining({ + dockerImage: expect.stringMatching(/^langsmith-snapshot-build:/), + fsCapacityBytes: 4294967296, + }), + ); + expect(fakeSandbox.delete).toHaveBeenCalledTimes(1); + } finally { + await rm(context, { recursive: true, force: true }); + } + }); + + it("createSnapshotFromDockerfile should default capacity and forward builder size", async () => { + const context = await mkdtemp(join(tmpdir(), "langsmith-docker-context-")); + const client = createClientWithMock(jest.fn()); + const fakeSandbox = { + name: "builder", + write: jest + .fn<(path: string, content: string | Uint8Array) => Promise>() + .mockResolvedValue(undefined), + run: jest + .fn< + ( + command: string, + ) => Promise<{ stdout: string; stderr: string; exit_code: number }> + >() + .mockResolvedValue({ stdout: "", stderr: "", exit_code: 0 }), + delete: jest.fn<() => Promise>().mockResolvedValue(undefined), + }; + const createSandboxSpy = jest + .spyOn(client, "createSandbox") + .mockResolvedValue(fakeSandbox as any); + const captureSnapshotSpy = jest + .spyOn(client, "captureSnapshot") + .mockResolvedValue({ + id: "snap-1", + name: "snap", + status: "ready", + fs_capacity_bytes: 4294967296, + }); + + try { + await writeFile(join(context, "Dockerfile"), "FROM scratch\n"); + + await client.createSnapshotFromDockerfile("snap", "Dockerfile", { + context, + vCpus: 2, + memBytes: 8589934592, + }); + + expect(createSandboxSpy).toHaveBeenCalledWith( + expect.objectContaining({ + vCpus: 2, + memBytes: 8589934592, + fsCapacityBytes: undefined, + }), + ); + expect(captureSnapshotSpy).toHaveBeenCalledWith( + "builder", + "snap", + expect.objectContaining({ fsCapacityBytes: undefined }), + ); + } finally { + await rm(context, { recursive: true, force: true }); + } + }); + + it("captureSnapshot should POST to /boxes/{name}/snapshot", async () => { + const mockFetch = jest + .fn() + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "snap-2", + name: "captured", + status: "building", + fs_capacity_bytes: 4294967296, + source_sandbox_id: "my-vm", + }), + } as Response) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: "snap-2", + name: "captured", + status: "ready", + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshot = await client.captureSnapshot("my-vm", "captured"); + + expect(snapshot.id).toBe("snap-2"); + expect(snapshot.status).toBe("ready"); + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/boxes/my-vm/snapshot"); + }); + + it("getSnapshot should return snapshot data", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "snap-1", + name: "my-env", + status: "ready", + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshot = await client.getSnapshot("snap-1"); + + expect(snapshot.id).toBe("snap-1"); + expect(snapshot.name).toBe("my-env"); + }); + + it("getSnapshot should throw ResourceNotFoundError on 404", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: "not found" }), + text: async () => "not found", + } as Response); + + const client = createClientWithMock(mockFetch); + await expect(client.getSnapshot("nonexistent")).rejects.toThrow( + LangSmithResourceNotFoundError, + ); + }); + + it("listSnapshots should return array", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + snapshots: [ + { + id: "snap-1", + name: "env-1", + status: "ready", + fs_capacity_bytes: 4294967296, + }, + { + id: "snap-2", + name: "env-2", + status: "building", + fs_capacity_bytes: 8589934592, + }, + ], + offset: 0, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshots = await client.listSnapshots(); + + expect(snapshots).toHaveLength(2); + expect(snapshots[0].name).toBe("env-1"); + expect(snapshots[1].status).toBe("building"); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(new URL(url).search).toBe(""); + }); + + it("listSnapshots should forward nameContains, limit, and offset as query params", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + snapshots: [ + { + id: "snap-1", + name: "env-1", + status: "ready", + }, + ], + offset: 5, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshots = await client.listSnapshots({ + nameContains: "env", + limit: 10, + offset: 5, + }); + + expect(snapshots).toHaveLength(1); + expect(snapshots[0].name).toBe("env-1"); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + const parsed = new URL(url); + expect(parsed.pathname.endsWith("/snapshots")).toBe(true); + expect(parsed.searchParams.get("name_contains")).toBe("env"); + expect(parsed.searchParams.get("limit")).toBe("10"); + expect(parsed.searchParams.get("offset")).toBe("5"); + }); + + it("deleteSnapshot should send DELETE request", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + } as Response); + + const client = createClientWithMock(mockFetch); + await client.deleteSnapshot("snap-1"); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/snapshots/snap-1"); + expect(init.method).toBe("DELETE"); + }); + + it("waitForSnapshot should return immediately if already ready", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "snap-1", + name: "env", + status: "ready", + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const snapshot = await client.waitForSnapshot("snap-1"); + expect(snapshot.status).toBe("ready"); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("waitForSnapshot should throw on failed status", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "snap-1", + name: "env", + status: "failed", + status_message: "Docker pull failed", + fs_capacity_bytes: 4294967296, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await expect(client.waitForSnapshot("snap-1")).rejects.toThrow( + LangSmithResourceCreationError, + ); + }); +}); + +describe("SandboxClient - start/stop", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("startSandbox should POST to /start and poll until ready", async () => { + const mockFetch = jest + .fn() + // POST /boxes/my-vm/start -> 202 + .mockResolvedValueOnce({ ok: true } as Response) + // GET /boxes/my-vm/status -> ready + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ status: "ready" }), + } as Response) + // GET /boxes/my-vm -> full sandbox + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + name: "my-vm", + status: "ready", + dataplane_url: "https://dp.example.com/my-vm", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const sandbox = await client.startSandbox("my-vm"); + + expect(sandbox.name).toBe("my-vm"); + expect(sandbox.status).toBe("ready"); + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/boxes/my-vm/start"); + }); + + it("startSandbox should throw ResourceNotFoundError on 404", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: "not found" }), + text: async () => "not found", + } as Response); + + const client = createClientWithMock(mockFetch); + await expect(client.startSandbox("nonexistent")).rejects.toThrow( + LangSmithResourceNotFoundError, + ); + }); + + it("stopSandbox should POST to /stop", async () => { + const mockFetch = jest + .fn() + .mockResolvedValue({ ok: true } as Response); + + const client = createClientWithMock(mockFetch); + await client.stopSandbox("my-vm"); + + const [url] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/boxes/my-vm/stop"); + }); + + it("stopSandbox should throw ResourceNotFoundError on 404", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: "not found" }), + text: async () => "not found", + } as Response); + + const client = createClientWithMock(mockFetch); + await expect(client.stopSandbox("nonexistent")).rejects.toThrow( + LangSmithResourceNotFoundError, + ); + }); +}); + +describe("SandboxClient - generateDownloadURL", () => { + const createClientWithMock = (mockFetch: any) => { + const client = new SandboxClient({ + apiEndpoint: "https://api.example.com/v2/sandboxes", + apiKey: "test-key", + }); + (client as any)._caller = { call: (fn: any) => fn() }; + (client as any)._fetchImpl = mockFetch; + return client; + }; + + it("should POST the path and return the link", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + download_url: "https://uuid--dl.svc.example.com/tok", + token: "tok", + expires_at: null, + }), + } as Response); + + const client = createClientWithMock(mockFetch); + const link = await client.generateDownloadURL("my-vm", "/tmp/report.pdf"); + + expect(link.download_url).toBe("https://uuid--dl.svc.example.com/tok"); + expect(link.expires_at).toBeNull(); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/boxes/my-vm/download-url"); + expect(JSON.parse(init.body as string)).toEqual({ + path: "/tmp/report.pdf", + }); + }); + + it("should send expiry and response headers when provided", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + download_url: "https://d/tok", + token: "tok", + expires_at: "2099-01-01T00:00:00Z", + }), + } as Response); + + const client = createClientWithMock(mockFetch); + await client.generateDownloadURL("my-vm", "/tmp/page.html", { + expiresInSeconds: 3600, + contentType: "text/html", + contentDisposition: "inline", + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + path: "/tmp/page.html", + expires_in_seconds: 3600, + content_type: "text/html", + content_disposition: "inline", + }); + }); + + it("should reject a non-positive expiry", async () => { + const mockFetch = jest.fn(); + const client = createClientWithMock(mockFetch); + + await expect( + client.generateDownloadURL("my-vm", "/tmp/f", { expiresInSeconds: 0 }), + ).rejects.toThrow(LangSmithValidationError); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("should throw ResourceNotFoundError on 404", async () => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + json: async () => ({ detail: "not found" }), + text: async () => "not found", + } as Response); + + const client = createClientWithMock(mockFetch); + await expect( + client.generateDownloadURL("nonexistent", "/tmp/f"), + ).rejects.toThrow(LangSmithResourceNotFoundError); + }); +}); + +describe("Sandbox - start/stop/captureSnapshot", () => { + it("start should update status and dataplane_url", async () => { + const mockClient = createMockClient({ + startSandbox: jest + .fn<(name: string, opts?: any) => Promise>() + .mockResolvedValue({ + name: "my-vm", + status: "ready", + dataplane_url: "https://dp.example.com/my-vm", + }), + }); + + const sandbox = new (Sandbox as any)( + { name: "my-vm", status: "stopped", snapshot_id: "snap-1" }, + mockClient, + ); + + await sandbox.start(); + + expect(sandbox.status).toBe("ready"); + expect(sandbox.dataplane_url).toBe("https://dp.example.com/my-vm"); + }); + + it("stop should set status to stopped and keep dataplane_url", async () => { + const mockClient = createMockClient({ + stopSandbox: jest + .fn<(name: string) => Promise>() + .mockResolvedValue(undefined), + }); + + const sandbox = new (Sandbox as any)( + { + name: "my-vm", + status: "ready", + dataplane_url: "https://dp.example.com/my-vm", + }, + mockClient, + ); + + await sandbox.stop(); + + expect(sandbox.status).toBe("stopped"); + expect(sandbox.dataplane_url).toBe("https://dp.example.com/my-vm"); + }); + + it("captureSnapshot should delegate to client", async () => { + const mockSnapshot = { + id: "snap-1", + name: "captured", + status: "ready", + fs_capacity_bytes: 4294967296, + }; + const mockClient = createMockClient({ + captureSnapshot: jest + .fn<(sandboxName: string, name: string, opts?: any) => Promise>() + .mockResolvedValue(mockSnapshot), + }); + + const sandbox = new (Sandbox as any)( + { name: "my-vm", status: "ready" }, + mockClient, + ); + + const snapshot = await sandbox.captureSnapshot("captured"); + + expect(snapshot).toEqual(mockSnapshot); + expect(mockClient.captureSnapshot).toHaveBeenCalledWith( + "my-vm", + "captured", + {}, + ); + }); +}); + +describe("SandboxClient registries", () => { + it("exposes a cached registries accessor backed by the generated client", () => { + const client = new SandboxClient({ + apiEndpoint: "https://api.smith.langchain.com/v2/sandboxes", + apiKey: "k", + }); + const first = client.registries; + expect(first).toBeDefined(); + // Lazily built once and reused. + expect(client.registries).toBe(first); + }); +}); diff --git a/src/lib/tests/sandbox_access_delegation.test.ts b/src/lib/tests/sandbox_access_delegation.test.ts new file mode 100644 index 0000000..4cfeed1 --- /dev/null +++ b/src/lib/tests/sandbox_access_delegation.test.ts @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest, describe, it, expect } from "@jest/globals"; +import { SandboxClient } from "../sandbox/client.js"; +import { Sandbox } from "../sandbox/sandbox.js"; +import type { AccessDelegation } from "../sandbox/access_delegation.js"; +import { LangSmithValidationError } from "../sandbox/errors.js"; + +const DATAPLANE = "https://sandbox-router.example.com/sb-123"; + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const clientWithMock = () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ name: "sb", status: "ready", dataplane_url: DATAPLANE }), + ); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + apiKey: "test-key", + }); + (client as any)._fetch = mockFetch; + return { client, mockFetch }; +}; + +const sentBody = (mockFetch: any) => { + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + return JSON.parse(init.body as string); +}; + +describe("access delegation on create", () => { + it("sends an INHERIT grant", async () => { + const { client, mockFetch } = clientWithMock(); + await client.createSandbox("snap-1", { + accessDelegation: { mode: "INHERIT" }, + }); + expect(sentBody(mockFetch).access_delegation).toEqual({ mode: "INHERIT" }); + }); + + it("sends an EXPLICIT grant with its permissions", async () => { + const { client, mockFetch } = clientWithMock(); + await client.createSandbox("snap-1", { + accessDelegation: { + mode: "EXPLICIT", + permissions: ["datasets:read", "tracer_sessions:read"], + }, + }); + expect(sentBody(mockFetch).access_delegation).toEqual({ + mode: "EXPLICIT", + permissions: ["datasets:read", "tracer_sessions:read"], + }); + }); + + it("omits the field when no grant is requested", async () => { + const { client, mockFetch } = clientWithMock(); + await client.createSandbox("snap-1"); + expect(sentBody(mockFetch)).not.toHaveProperty("access_delegation"); + }); + + // Rejected client-side so the caller does not pay a round trip for a 422. + it.each([ + [{ mode: "MAYBE" }, /"INHERIT" or "EXPLICIT"/], + [{ mode: "INHERIT", permissions: ["a"] }, /not allowed with mode/], + [{ mode: "EXPLICIT" }, /required with mode/], + [{ mode: "EXPLICIT", permissions: "a" }, /array of strings/], + ])("rejects %j", async (grant, message) => { + const { client, mockFetch } = clientWithMock(); + await expect( + client.createSandbox("snap-1", { + accessDelegation: grant as unknown as AccessDelegation, + }), + ).rejects.toThrow(LangSmithValidationError); + await expect( + client.createSandbox("snap-1", { + accessDelegation: grant as unknown as AccessDelegation, + }), + ).rejects.toThrow(message); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("access delegation on the response", () => { + it("is exposed when the sandbox has one", () => { + const sandbox = new Sandbox( + { + name: "sb", + dataplane_url: DATAPLANE, + access_delegation: { mode: "EXPLICIT", permissions: ["datasets:read"] }, + }, + {} as unknown as SandboxClient, + ); + expect(sandbox.access_delegation).toEqual({ + mode: "EXPLICIT", + permissions: ["datasets:read"], + }); + }); + + it("is absent when it has none", () => { + const sandbox = new Sandbox( + { name: "sb", dataplane_url: DATAPLANE }, + {} as unknown as SandboxClient, + ); + expect(sandbox.access_delegation).toBeUndefined(); + }); +}); diff --git a/src/lib/tests/sandbox_aws_role_auth.test.ts b/src/lib/tests/sandbox_aws_role_auth.test.ts new file mode 100644 index 0000000..4a329d8 --- /dev/null +++ b/src/lib/tests/sandbox_aws_role_auth.test.ts @@ -0,0 +1,319 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { + SandboxClient, + awsAuth, + gcpAuth, + gcsMount, + mountConfig, + opaqueSecret, + proxyConfig, + s3Mount, + workspaceSecret, +} from "../sandbox/index.js"; +import type { + CreateSandboxOptions, + SandboxAwsAuthRule, + SandboxAwsMountAuthConfig, + SandboxMount, + SandboxMountAuthConfig, +} from "../sandbox/index.js"; + +import type * as SandboxTypes from "../sandbox/index.js"; + +const roleArn = "arn:aws:iam::123456789012:role/SandboxTest"; +const mounts = () => [ + s3Mount({ id: "data", mountPath: "/mnt/data", bucket: "test-bucket" }), +]; + +// These calls are checked by tsc, not executed by Jest. +function checkAuthAlternatives() { + const key = workspaceSecret("AWS_KEY_ID_REF"); + const secret = workspaceSecret("AWS_KEY_VALUE_REF"); + const staticRule = awsAuth({ accessKeyId: key, secretAccessKey: secret }); + const roleRule = awsAuth({ roleArn }); + const keyValue: string = staticRule.aws.access_key_id.value; + const roleValue: string = roleRule.aws.role_arn; + const staticMountAuth: SandboxAwsMountAuthConfig = staticRule.aws; + const staticMountKey: string = staticMountAuth.access_key_id.value; + const roleMountAuth: SandboxMountAuthConfig = { aws: roleRule.aws }; + // @ts-expect-error static auth variants are not new public SDK types + const privateStatic: SandboxTypes.SandboxAwsStaticAuthConfig = staticRule.aws; + // @ts-expect-error role auth variants are not new public SDK types + const privateRole: SandboxTypes.SandboxAwsRoleAuthConfig = roleRule.aws; + // @ts-expect-error mount roles do not need a separate public auth type + const privateMountRole: SandboxTypes.SandboxAwsMountRoleAuthConfig = + roleRule.aws; + // @ts-expect-error role and static alternatives are exclusive + awsAuth({ roleArn, accessKeyId: key, secretAccessKey: secret }); + // @ts-expect-error static auth requires both keys + awsAuth({ accessKeyId: key }); + // @ts-expect-error static auth requires both keys + awsAuth({ secretAccessKey: secret }); + // @ts-expect-error an authentication alternative is required + awsAuth({}); + const mixed: SandboxAwsAuthRule = { + name: "bad", + type: "aws", + // @ts-expect-error raw AWS rules also require exclusive alternatives + aws: { role_arn: roleArn, access_key_id: key, secret_access_key: secret }, + }; + const empty: SandboxAwsAuthRule = { + name: "bad", + type: "aws", + // @ts-expect-error raw AWS rules require an authentication alternative + aws: {}, + }; + const partial: SandboxMountAuthConfig = { + // @ts-expect-error raw mount auth requires both static keys + aws: { access_key_id: key }, + }; + const mixedMount: SandboxMountAuthConfig = { + // @ts-expect-error raw mount auth requires exclusive alternatives + aws: { role_arn: roleArn, access_key_id: key, secret_access_key: secret }, + }; + void [ + keyValue, + roleValue, + staticMountKey, + roleMountAuth, + privateStatic, + privateRole, + privateMountRole, + mixed, + empty, + partial, + mixedMount, + ]; +} +void checkAuthAlternatives; + +describe("sandbox AWS role auth", () => { + it("builds a role-only descriptor", () => { + expect( + awsAuth({ roleArn, name: "role", envVars: { AWS_REGION: "us-east-1" } }), + ).toEqual({ + name: "role", + type: "aws", + enabled: true, + env_vars: { AWS_REGION: "us-east-1" }, + aws: { role_arn: roleArn }, + }); + }); + + it.each([workspaceSecret, opaqueSecret])( + "preserves static credential payloads", + (secret) => { + const options = { + accessKeyId: secret("AWS_KEY_ID_REF"), + secretAccessKey: secret("AWS_KEY_VALUE_REF"), + }; + const rule = awsAuth(options); + expect(rule.aws).toEqual({ + access_key_id: options.accessKeyId, + secret_access_key: options.secretAccessKey, + }); + expect(awsAuth({ ...options, roleArn: "" }).aws).toEqual(rule.aws); + expect(mountConfig({ auth: [rule], mounts: mounts() }).auth.aws).toEqual( + rule.aws, + ); + }, + ); + + it.each([ + {}, + { roleArn: "" }, + { roleArn: " " }, + { roleArn: 42 }, + { accessKeyId: workspaceSecret("AWS_KEY_ID_REF") }, + { roleArn, accessKeyId: workspaceSecret("AWS_KEY_ID_REF") }, + { roleArn, secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF") }, + ])("rejects incomplete and mixed helper options", (options) => { + expect(() => awsAuth(options as never)).toThrow(); + }); + + it.each([ + { access_key_id: null }, + { secret_access_key: null }, + { external_id: "caller-controlled" }, + { session_token: "not-accepted" }, + ])("rejects extra fields in raw role descriptors", (extra) => { + const rule = { + name: "role", + type: "aws", + enabled: true, + aws: { role_arn: roleArn, ...extra }, + }; + expect(() => proxyConfig({ rules: [rule] })).toThrow("only role_arn"); + expect(() => + mountConfig({ auth: [rule as never], mounts: mounts() }), + ).toThrow("only role_arn"); + }); + + it("preserves mount-scoped roles under auth.aws", () => { + expect( + mountConfig({ auth: [awsAuth({ roleArn })], mounts: mounts() }).auth, + ).toEqual({ aws: { role_arn: roleArn } }); + }); + + it("does not turn general proxy auth into mount-scoped auth", () => { + const proxy = proxyConfig({ rules: [awsAuth({ roleArn })] }); + expect(mountConfig({ proxyConfig: proxy, mounts: mounts() })).toEqual({ + auth: {}, + mounts: mounts(), + }); + expect(() => + mountConfig({ + proxyConfig: proxy, + auth: [awsAuth({ roleArn })], + mounts: mounts(), + }), + ).toThrow("both mountConfig and proxyConfig"); + }); + + it.each([ + undefined, + { rules: [] }, + proxyConfig({ rules: [awsAuth({ roleArn, enabled: false })] }), + ])("requires enabled auth for mounts", (proxy) => { + expect(() => mountConfig({ proxyConfig: proxy, mounts: mounts() })).toThrow( + "s3 mounts require aws auth", + ); + }); + + it.each([true, false])( + "does not use GCP proxy auth for a GCS mount (enabled=%s)", + (enabled) => { + const proxy = proxyConfig({ + rules: [ + gcpAuth({ + serviceAccountJson: workspaceSecret("GCP_SERVICE_ACCOUNT"), + scopes: ["https://www.googleapis.com/auth/devstorage.read_only"], + enabled, + }), + ], + }); + expect(() => + mountConfig({ + proxyConfig: proxy, + mounts: [ + gcsMount({ + id: "gcs", + mountPath: "/mnt/gcs", + bucket: "test-bucket", + }), + ], + }), + ).toThrow("gcs mounts require gcp auth"); + }, + ); + + it.each([false, true])( + "preserves explicit GCS mount auth (shared AWS=%s)", + (sharedAws) => { + const secret = workspaceSecret("GCP_SERVICE_ACCOUNT"); + const allMounts: SandboxMount[] = [ + gcsMount({ id: "gcs", mountPath: "/mnt/gcs", bucket: "test-bucket" }), + ]; + const proxy = sharedAws + ? proxyConfig({ rules: [awsAuth({ roleArn })] }) + : undefined; + if (sharedAws) allMounts.push(...mounts()); + const config = mountConfig({ + auth: [gcpAuth({ serviceAccountJson: secret })], + proxyConfig: proxy, + mounts: allMounts, + }); + expect(config.auth).toEqual({ gcp: { service_account_json: secret } }); + expect(config.mounts).toEqual(allMounts); + }, + ); + + it("does not copy shared static AWS auth into mount auth", () => { + const proxy = proxyConfig({ + rules: [ + awsAuth({ + accessKeyId: workspaceSecret("AWS_KEY_ID_REF"), + secretAccessKey: workspaceSecret("AWS_KEY_VALUE_REF"), + }), + ], + }); + expect(mountConfig({ proxyConfig: proxy, mounts: mounts() })).toEqual({ + auth: {}, + mounts: mounts(), + }); + }); + + it.each(["proxy", "mount", "shared"])( + "sends the correct %s auth shape at creation", + async (mode) => { + const options: CreateSandboxOptions = {}; + if (mode !== "mount") + options.proxyConfig = proxyConfig({ rules: [awsAuth({ roleArn })] }); + if (mode === "mount") + options.mountConfig = mountConfig({ + auth: [awsAuth({ roleArn })], + mounts: mounts(), + }); + if (mode === "shared") + options.mountConfig = mountConfig({ + proxyConfig: options.proxyConfig, + mounts: mounts(), + }); + const mockFetch = jest.fn().mockResolvedValue( + new Response(JSON.stringify({ name: "role-box", status: "ready" }), { + status: 201, + }), + ); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + }); + jest.spyOn(client, "_fetch").mockImplementation(mockFetch); + expect((await client.createSandbox("snap-1", options)).name).toBe( + "role-box", + ); + const body = JSON.parse(mockFetch.mock.calls[0][1]?.body as string); + expect(body.proxy_config).toEqual(options.proxyConfig); + expect(body.mount_config).toEqual(options.mountConfig); + }, + ); + + it("reads mixed static and role sandboxes through list/get", async () => { + const staticAuth = { + access_key_id: { type: "opaque" }, + secret_access_key: { type: "opaque" }, + }; + const boxes = [ + { + name: "static-box", + mount_config: { auth: { aws: staticAuth }, mounts: mounts() }, + }, + { + name: "role-box", + mount_config: { + auth: { aws: { role_arn: roleArn } }, + mounts: mounts(), + }, + }, + { + name: "proxy-box", + proxy_config: proxyConfig({ rules: [awsAuth({ roleArn })] }), + }, + ]; + const mockFetch = jest + .fn() + .mockResolvedValueOnce( + new Response(JSON.stringify({ sandboxes: boxes })), + ); + for (const box of boxes) + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify(box))); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + }); + jest.spyOn(client, "_fetch").mockImplementation(mockFetch); + expect((await client.listSandboxes()).map((box) => box.name)).toEqual( + boxes.map((box) => box.name), + ); + for (const box of boxes) + expect((await client.getSandbox(box.name)).name).toBe(box.name); + }); +}); diff --git a/src/lib/tests/sandbox_command_id_retry.test.ts b/src/lib/tests/sandbox_command_id_retry.test.ts new file mode 100644 index 0000000..5a6d5d6 --- /dev/null +++ b/src/lib/tests/sandbox_command_id_retry.test.ts @@ -0,0 +1,368 @@ +/** + * Idempotent retry when a command WebSocket fails before 'started'. + * + * run() sends a client-generated command_id and the server does get-or-create + * keyed on it, so re-issuing a command whose tunnel closed before 'started' — + * or whose connect never completed — reattaches to the same session instead of + * spawning a second one. Transient 5xx handshake rejections are safe to retry; + * permanent 4xx rejections still propagate immediately. + */ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import type { WsMessage } from "../sandbox/types.js"; +import { + LangSmithSandboxConnectionError, + LangSmithSandboxConnectTimeoutError, + LangSmithSandboxRetryableConnectionError, +} from "../sandbox/errors.js"; + +// Virtual clock so budget assertions do not depend on real elapsed time. +const clock = { now: 0 }; + +const runWsStream = jest.fn(); +const reconnectWsStream = jest.fn(); + +jest.unstable_mockModule("../sandbox/ws_execute.js", () => ({ + runWsStream, + reconnectWsStream, + WSStreamControl: class {}, + isWsAvailable: () => Promise.resolve(true), + WS_OPEN_TIMEOUT: 30, + WS_CONNECT_BUDGET: 120, + connectDeadline: () => clock.now + 120, + remainingBudget: (deadline?: number) => + deadline === undefined ? undefined : deadline - clock.now, + openTimeoutFor: (deadline?: number) => + deadline === undefined + ? 30 + : Math.min(30, Math.max(deadline - clock.now, 0)), +})); + +const { Sandbox } = await import("../sandbox/sandbox.js"); + +function makeStream(messages: WsMessage[]): AsyncIterableIterator { + let i = 0; + return { + next: async () => + i < messages.length + ? { value: messages[i++], done: false } + : { value: undefined as never, done: true }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +function failingStream(err: Error): AsyncIterableIterator { + return { + next: async () => { + throw err; + }, + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterableIterator; +} + +function makeSandbox() { + const client = { + getApiKey: () => "test-key", + getDefaultHeaders: () => ({}), + }; + return new Sandbox( + { + name: "sb", + dataplane_url: "https://dp.example.com/sb-123", + status: "ready", + } as never, + client as never, + ); +} + +describe("run() early-close retry", () => { + beforeEach(() => { + runWsStream.mockReset(); + clock.now = 0; + }); + + it("retries with the same command_id after a close before 'started'", async () => { + const sandbox = makeSandbox(); + + runWsStream + .mockImplementationOnce((..._args: unknown[]) => [makeStream([]), null]) + .mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + makeStream([ + { type: "started", command_id: opts.commandId, pid: 1 }, + { type: "exit", exit_code: 0 }, + ]), + null, + ]; + }); + + const result = await sandbox.run("echo hi"); + + expect(result.exit_code).toBe(0); + expect(runWsStream).toHaveBeenCalledTimes(2); + const first = runWsStream.mock.calls[0][3] as { commandId: string }; + const second = runWsStream.mock.calls[1][3] as { commandId: string }; + expect(first.commandId).toBe(second.commandId); + }); + + it("retries with the same command_id after a failed connect", async () => { + const sandbox = makeSandbox(); + + runWsStream + .mockImplementationOnce((..._args: unknown[]) => [ + failingStream(new LangSmithSandboxConnectTimeoutError("timed out")), + null, + ]) + .mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + makeStream([ + { type: "started", command_id: opts.commandId, pid: 1 }, + { type: "exit", exit_code: 0 }, + ]), + null, + ]; + }); + + const result = await sandbox.run("echo hi"); + + expect(result.exit_code).toBe(0); + expect(runWsStream).toHaveBeenCalledTimes(2); + const first = runWsStream.mock.calls[0][3] as { commandId: string }; + const second = runWsStream.mock.calls[1][3] as { commandId: string }; + expect(first.commandId).toBe(second.commandId); + }); + + it("retries a transient rejected handshake with the same command_id", async () => { + const sandbox = makeSandbox(); + + runWsStream + .mockImplementationOnce((..._args: unknown[]) => [ + failingStream( + new LangSmithSandboxRetryableConnectionError( + "WebSocket upgrade rejected by server (HTTP 503)", + ), + ), + null, + ]) + .mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + makeStream([ + { type: "started", command_id: opts.commandId, pid: 1 }, + { type: "exit", exit_code: 0 }, + ]), + null, + ]; + }); + + const result = await sandbox.run("echo hi"); + + expect(result.exit_code).toBe(0); + expect(runWsStream).toHaveBeenCalledTimes(2); + const first = runWsStream.mock.calls[0][3] as { commandId: string }; + const second = runWsStream.mock.calls[1][3] as { commandId: string }; + expect(first.commandId).toBe(second.commandId); + }); + + it("waits for Retry-After before retrying a rate-limited handshake", async () => { + jest.useFakeTimers(); + try { + const sandbox = makeSandbox(); + + runWsStream + .mockImplementationOnce((..._args: unknown[]) => [ + failingStream( + new LangSmithSandboxRetryableConnectionError("HTTP 429", 10), + ), + null, + ]) + .mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + makeStream([ + { type: "started", command_id: opts.commandId, pid: 1 }, + { type: "exit", exit_code: 0 }, + ]), + null, + ]; + }); + + const resultPromise = sandbox.run("echo hi"); + await jest.advanceTimersByTimeAsync(9_999); + expect(runWsStream).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1); + const result = await resultPromise; + + expect(result.exit_code).toBe(0); + const first = runWsStream.mock.calls[0][3] as { commandId: string }; + const second = runWsStream.mock.calls[1][3] as { commandId: string }; + expect(first.commandId).toBe(second.commandId); + } finally { + jest.useRealTimers(); + } + }); + + it("jitters retries without Retry-After", async () => { + jest.useFakeTimers(); + const random = jest.spyOn(Math, "random").mockReturnValue(0.5); + try { + const sandbox = makeSandbox(); + + runWsStream + .mockImplementationOnce((..._args: unknown[]) => [ + failingStream( + new LangSmithSandboxRetryableConnectionError("HTTP 429"), + ), + null, + ]) + .mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + makeStream([ + { type: "started", command_id: opts.commandId, pid: 1 }, + { type: "exit", exit_code: 0 }, + ]), + null, + ]; + }); + + const resultPromise = sandbox.run("echo hi"); + await jest.advanceTimersByTimeAsync(449); + expect(runWsStream).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(1); + expect(runWsStream).toHaveBeenCalledTimes(2); + await expect(resultPromise).resolves.toMatchObject({ exit_code: 0 }); + } finally { + random.mockRestore(); + jest.useRealTimers(); + } + }); + + it("does not retry a rejected handshake", async () => { + const sandbox = makeSandbox(); + runWsStream.mockImplementation((..._args: unknown[]) => [ + failingStream( + new LangSmithSandboxConnectionError( + "WebSocket upgrade rejected by server (HTTP 404)", + ), + ), + null, + ]); + + await expect(sandbox.run("echo hi")).rejects.toThrow("HTTP 404"); + expect(runWsStream).toHaveBeenCalledTimes(1); + }); + + it("caps total connect wall clock at the budget", async () => { + const sandbox = makeSandbox(); + const opens: (number | undefined)[] = []; + + runWsStream.mockImplementation((...args: unknown[]) => { + const opts = args[3] as { openTimeout?: number }; + opens.push(opts.openTimeout); + // Each attempt burns its full open timeout. + clock.now += opts.openTimeout ?? 0; + return [ + failingStream(new LangSmithSandboxConnectTimeoutError("timed out")), + null, + ]; + }); + + await expect(sandbox.run("echo hi")).rejects.toThrow( + LangSmithSandboxConnectTimeoutError, + ); + + expect(clock.now).toBeLessThanOrEqual(120); + expect(opens[0]).toBe(30); + const total = opens.reduce((a, b) => (a ?? 0) + (b ?? 0), 0) ?? 0; + expect(total).toBeLessThanOrEqual(120); + }); + + it("never starts an attempt once the budget is spent", async () => { + const sandbox = makeSandbox(); + const opens: (number | undefined)[] = []; + + runWsStream.mockImplementation((...args: unknown[]) => { + const opts = args[3] as { openTimeout?: number }; + opens.push(opts.openTimeout); + // Land just short of the deadline so the clamped backoff spends the rest. + clock.now = 119.9; + return [ + failingStream(new LangSmithSandboxConnectTimeoutError("timed out")), + null, + ]; + }); + + await expect(sandbox.run("echo hi")).rejects.toThrow( + LangSmithSandboxConnectTimeoutError, + ); + + // A 0 openTimeout would disable ws's handshakeTimeout entirely. + expect(opens).not.toContain(0); + }); + + it("retries a connect timeout while reconnecting a live command", async () => { + const sandbox = makeSandbox(); + + // Stream dies mid-command; the handle must reconnect to finish it. + runWsStream.mockImplementationOnce((...args: unknown[]) => { + const opts = args[3] as { commandId: string }; + return [ + (function () { + const msgs: WsMessage[] = [ + { type: "started", command_id: opts.commandId, pid: 1 }, + ]; + let i = 0; + return { + next: async () => { + if (i < msgs.length) return { value: msgs[i++], done: false }; + throw new LangSmithSandboxConnectionError("connection lost"); + }, + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterableIterator; + })(), + null, + ]; + }); + + // First reconnect attempt fails at the socket level (the subclass that a + // name-based check would have let escape), the second succeeds. + reconnectWsStream + .mockImplementationOnce(() => [ + failingStream(new LangSmithSandboxConnectTimeoutError("timed out")), + null, + ]) + .mockImplementationOnce(() => [ + makeStream([{ type: "exit", exit_code: 0 }]), + null, + ]); + + const handle = await sandbox.run("echo hi", { wait: false }); + const result = await handle.result; + + expect(result.exit_code).toBe(0); + expect(reconnectWsStream).toHaveBeenCalledTimes(2); + }); + + it("does not retry a non-early-close error (e.g. wrong first frame)", async () => { + const sandbox = makeSandbox(); + runWsStream.mockImplementation((..._args: unknown[]) => [ + makeStream([{ type: "stdout", data: "oops", offset: 0 }]), + null, + ]); + + await expect(sandbox.run("echo hi", { wait: false })).rejects.toThrow( + "Expected 'started'", + ); + expect(runWsStream).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/tests/sandbox_error_handling.test.ts b/src/lib/tests/sandbox_error_handling.test.ts new file mode 100644 index 0000000..e708400 --- /dev/null +++ b/src/lib/tests/sandbox_error_handling.test.ts @@ -0,0 +1,126 @@ +import { createServer, Server } from "http"; +import { describe, it, expect, beforeAll, afterAll } from "@jest/globals"; +import { SandboxClient } from "../sandbox/client.js"; +import { + LangSmithValidationError, + LangSmithSandboxCreationError, + LangSmithSandboxAPIError, +} from "../sandbox/errors.js"; + +let server: Server; +let port: number; +let nextResponse: { status: number; body: unknown }; + +beforeAll((done) => { + server = createServer((_req, res) => { + res.writeHead(nextResponse.status, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify(nextResponse.body)); + }); + server.listen(0, () => { + port = (server.address() as any).port; + done(); + }); +}); + +afterAll((done) => { + server.close(done); +}); + +function makeClient(): SandboxClient { + return new SandboxClient({ + apiEndpoint: `http://localhost:${port}/v2/sandboxes`, + apiKey: "test-key", + maxRetries: 0, + }); +} + +describe("handleClientHttpError body consumption", () => { + it("preserves the server error ID", async () => { + nextResponse = { + status: 500, + body: { + detail: { + error: "SandboxSnapshotFailed", + message: "Snapshot capture failed.", + error_id: "6a39f608-e9aa-4247-8e61-846870563681", + }, + }, + }; + + const client = makeClient(); + + await expect( + client.captureSnapshot("test-sandbox", "my-snapshot"), + ).rejects.toThrow( + new LangSmithSandboxAPIError( + "Snapshot capture failed. (error_id=6a39f608-e9aa-4247-8e61-846870563681)", + ), + ); + }); + + it("handles 422 with pydantic validation details without crashing", async () => { + nextResponse = { + status: 422, + body: { + detail: [ + { + loc: ["body", "name"], + msg: "field required", + type: "value_error.missing", + }, + ], + }, + }; + + const client = makeClient(); + + // Bug: handleClientHttpError calls parseErrorResponse (consuming body), + // then response.clone() — crashes with "Body has already been consumed" + await expect( + client.captureSnapshot("test-sandbox", "my-snapshot"), + ).rejects.toThrow(LangSmithValidationError); + }); +}); + +describe("handleSandboxCreationError body consumption", () => { + it("handles 422 pydantic value_error during sandbox creation without crashing", async () => { + nextResponse = { + status: 422, + body: { + detail: [ + { + loc: ["body", "vcpus"], + msg: "ensure this value is greater than 0", + type: "value_error", + }, + ], + }, + }; + + const client = makeClient(); + + await expect(client.createSandbox("snap-123")).rejects.toThrow( + LangSmithValidationError, + ); + }); + + it("handles 422 runtime creation error during sandbox creation", async () => { + nextResponse = { + status: 422, + body: { + detail: { + error: "ImagePullFailed", + message: "Failed to pull image foo:latest", + }, + }, + }; + + const client = makeClient(); + + await expect(client.createSandbox("snap-123")).rejects.toThrow( + LangSmithSandboxCreationError, + ); + }); +}); diff --git a/src/lib/tests/sandbox_reconnect_ack.test.ts b/src/lib/tests/sandbox_reconnect_ack.test.ts new file mode 100644 index 0000000..5cf0037 --- /dev/null +++ b/src/lib/tests/sandbox_reconnect_ack.test.ts @@ -0,0 +1,207 @@ +/** + * A silent command must outlive more socket losses than the reconnect budget. + * + * A command and its WebSocket are separate things: the command keeps running on + * the server and the socket is only this client's attachment to it. The server + * acknowledges every successful reattachment with a 'started' frame, which for a + * command producing no output is the only evidence the reattachment landed. + * + * The budget is meant to bound *consecutive failed* reattachments. Reset it only + * on stdout/stderr and it instead counts socket closures since the last output, + * so a healthy, attached, quiet command dies one loss at a time. + */ +import { jest, describe, it, expect, beforeEach } from "@jest/globals"; +import type { WsMessage } from "../sandbox/types.js"; +import { LangSmithSandboxConnectionError } from "../sandbox/errors.js"; + +const runWsStream = jest.fn(); +const reconnectWsStream = jest.fn(); + +jest.unstable_mockModule("../sandbox/ws_execute.js", () => ({ + runWsStream, + reconnectWsStream, + WSStreamControl: class {}, + isWsAvailable: () => Promise.resolve(true), + WS_OPEN_TIMEOUT: 30, + WS_CONNECT_BUDGET: 120, + connectDeadline: () => 120, + remainingBudget: () => 120, + openTimeoutFor: () => 30, +})); + +const { Sandbox } = await import("../sandbox/sandbox.js"); +const { CommandHandle } = await import("../sandbox/command_handle.js"); + +const COMMAND_ID = "cmd-123"; + +function makeStream(messages: WsMessage[]): AsyncIterableIterator { + let i = 0; + return { + next: async () => + i < messages.length + ? { value: messages[i++], done: false } + : { value: undefined as never, done: true }, + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +/** Yields the given frames, then loses the socket. */ +function thenLost( + messages: WsMessage[], + err: Error, +): AsyncIterableIterator { + let i = 0; + return { + next: async () => { + if (i < messages.length) return { value: messages[i++], done: false }; + throw err; + }, + [Symbol.asyncIterator]() { + return this; + }, + } as AsyncIterableIterator; +} + +function startedMsg(commandId: string = COMMAND_ID): WsMessage { + return { type: "started", command_id: commandId, pid: 42 } as WsMessage; +} + +function makeSandbox() { + const client = { + getApiKey: () => "test-key", + getDefaultHeaders: () => ({}), + }; + return new Sandbox( + { + name: "sb", + dataplane_url: "https://dp.example.com/sb-123", + status: "ready", + } as never, + client as never, + ); +} + +/** A handle already attached to a running command, whose socket then drops. */ +async function handleThatLosesItsSocket(sandbox: InstanceType) { + runWsStream.mockImplementationOnce(() => [ + thenLost( + [startedMsg()], + new LangSmithSandboxConnectionError("connection lost"), + ), + null, + ]); + return (await sandbox.run("sleep 60", { + wait: false, + commandId: COMMAND_ID, + })) as InstanceType; +} + +describe("reconnect acknowledgement", () => { + beforeEach(() => { + runWsStream.mockReset(); + reconnectWsStream.mockReset(); + jest.spyOn(global, "setTimeout").mockImplementation(((fn: () => void) => { + fn(); + return 0 as never; + }) as never); + }); + + it("resets the budget on a 'started' ack with no output", async () => { + const sandbox = makeSandbox(); + const losses = CommandHandle.MAX_AUTO_RECONNECTS + 3; + let attempts = 0; + + reconnectWsStream.mockImplementation(() => { + attempts += 1; + if (attempts < losses) { + return [ + thenLost( + [startedMsg()], + new LangSmithSandboxConnectionError("lost again"), + ), + null, + ]; + } + return [ + makeStream([startedMsg(), { type: "exit", exit_code: 7 } as WsMessage]), + null, + ]; + }); + + const handle = await handleThatLosesItsSocket(sandbox); + const chunks = []; + for await (const chunk of handle) chunks.push(chunk); + + expect(chunks).toEqual([]); + expect(attempts).toBe(losses); + expect((await handle.result).exit_code).toBe(7); + }); + + it("does not reset the budget on an ack for a different command", async () => { + const sandbox = makeSandbox(); + + reconnectWsStream.mockImplementation(() => [ + thenLost( + [startedMsg("someone-elses-cmd")], + new LangSmithSandboxConnectionError("lost again"), + ), + null, + ]); + + const handle = await handleThatLosesItsSocket(sandbox); + await expect( + (async () => { + for await (const _ of handle) { + /* drain */ + } + })(), + ).rejects.toThrow(/giving up/); + expect(reconnectWsStream).toHaveBeenCalledTimes( + CommandHandle.MAX_AUTO_RECONNECTS, + ); + }); + + it("consumes the ack without yielding a chunk or moving offsets", async () => { + const sandbox = makeSandbox(); + const seen: string[] = []; + + runWsStream.mockImplementationOnce(() => [ + thenLost( + [ + startedMsg(), + { type: "stdout", data: "before", offset: 0 } as WsMessage, + ], + new LangSmithSandboxConnectionError("connection lost"), + ), + null, + ]); + reconnectWsStream.mockImplementationOnce(() => [ + makeStream([ + startedMsg(), + { type: "stdout", data: "after", offset: 6 } as WsMessage, + { type: "exit", exit_code: 0 } as WsMessage, + ]), + null, + ]); + + const handle = (await sandbox.run("cmd", { + wait: false, + commandId: COMMAND_ID, + onStdout: (d: string) => seen.push(d), + })) as InstanceType; + + const chunks = []; + for await (const chunk of handle) chunks.push(chunk); + + expect(chunks.map((c) => c.data)).toEqual(["before", "after"]); + expect(seen).toEqual(["before", "after"]); + // Offsets are the ones "before" left behind: the ack carried no bytes. + expect(reconnectWsStream.mock.calls[0][3]).toMatchObject({ + stdoutOffset: 6, + stderrOffset: 0, + }); + expect((await handle.result).stdout).toBe("beforeafter"); + }); +}); diff --git a/src/lib/tests/sandbox_run_config_and_files.test.ts b/src/lib/tests/sandbox_run_config_and_files.test.ts new file mode 100644 index 0000000..1218845 --- /dev/null +++ b/src/lib/tests/sandbox_run_config_and_files.test.ts @@ -0,0 +1,437 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest, describe, it, expect } from "@jest/globals"; +import { SandboxClient } from "../sandbox/client.js"; +import { Sandbox } from "../sandbox/sandbox.js"; +import { CommandHandle } from "../sandbox/command_handle.js"; +import { WSStreamControl } from "../sandbox/ws_execute.js"; +import { + buildRangeHeader, + fileChunkFromResponse, + fileStatFromResponse, + resolveCloseInput, +} from "../sandbox/helpers.js"; +import { + LangSmithSandboxOperationError, + LangSmithValidationError, +} from "../sandbox/errors.js"; + +const DATAPLANE = "https://sandbox-router.example.com/sb-123"; + +const jsonResponse = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + ...init, + }); + +const createMockClient = (fetchImpl: any) => + ({ + _fetch: fetchImpl, + getApiKey: () => "test-key", + getDefaultHeaders: () => ({}), + deleteSandbox: jest.fn<() => Promise>().mockResolvedValue(undefined), + }) as unknown as SandboxClient; + +const makeSandbox = (fetchImpl: any) => + new Sandbox( + { name: "test-sandbox", dataplane_url: DATAPLANE }, + createMockClient(fetchImpl), + ); + +const forceHttpFallback = (sandbox: any) => + jest.spyOn(sandbox, "_wsAvailable").mockResolvedValue(false); + +describe("resolveCloseInput", () => { + it("defaults on for a non-PTY command", () => { + expect(resolveCloseInput(undefined, false)).toBe(true); + }); + + it("stays off under a PTY, even when asked for", () => { + expect(resolveCloseInput(true, true)).toBe(false); + expect(resolveCloseInput(undefined, true)).toBe(false); + }); + + it("honours an explicit opt-out", () => { + expect(resolveCloseInput(false, false)).toBe(false); + }); +}); + +describe("run config on requests", () => { + it("createSandbox sends run_config", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ name: "sb", status: "ready", dataplane_url: DATAPLANE }), + ); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + apiKey: "test-key", + }); + (client as any)._fetch = mockFetch; + + await client.createSandbox("snap-1", { + runConfig: { user: "app", env_vars: { A: "1" } }, + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string).run_config).toEqual({ + user: "app", + env_vars: { A: "1" }, + }); + }); + + it("updateSandbox sends run_config", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue(jsonResponse({ name: "sb", status: "ready" })); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + apiKey: "test-key", + }); + (client as any)._fetch = mockFetch; + + await client.updateSandbox("sb", { runConfig: { work_dir: "/srv" } }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string).run_config).toEqual({ + work_dir: "/srv", + }); + }); + + it("run sends run_config on the HTTP fallback", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ stdout: "", stderr: "", exit_code: 0 }), + ); + const sandbox = makeSandbox(mockFetch); + forceHttpFallback(sandbox); + + await sandbox.run("echo hi", { runConfig: { user: "app" } }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string).run_config).toEqual({ + user: "app", + }); + }); + + it.each([{ env: { A: "1" } }, { cwd: "/tmp" }])( + "rejects run_config combined with %p", + async (deprecated) => { + const sandbox = makeSandbox( + jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue(jsonResponse({})), + ); + await expect( + sandbox.run("echo hi", { runConfig: { user: "app" }, ...deprecated }), + ).rejects.toThrow(LangSmithValidationError); + }, + ); + + it("still accepts the deprecated env/cwd on their own", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ stdout: "", stderr: "", exit_code: 0 }), + ); + const sandbox = makeSandbox(mockFetch); + forceHttpFallback(sandbox); + + await sandbox.run("echo hi", { cwd: "/tmp", env: { A: "1" } }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.cwd).toBe("/tmp"); + expect(body.env).toEqual({ A: "1" }); + expect(body.run_config).toBeUndefined(); + }); +}); + +describe("closeInput on the command handle", () => { + const makeHandle = (stdinClosed: boolean, pty = false) => { + const control = new WSStreamControl(); + const sendInput = jest + .spyOn(control, "sendInput") + .mockImplementation(() => {}); + const sendCloseStdin = jest + .spyOn(control, "sendCloseStdin") + .mockImplementation(() => {}); + const handle = new CommandHandle( + (async function* () {})() as any, + control, + {} as any, + { commandId: "cmd-1", stdinClosed, pty }, + ); + return { handle, sendInput, sendCloseStdin }; + }; + + it("sendInput throws once stdin is closed", () => { + const { handle, sendInput } = makeHandle(true); + expect(() => handle.sendInput("data")).toThrow( + LangSmithSandboxOperationError, + ); + expect(() => handle.sendInput("data")).toThrow("closeInput: false"); + expect(sendInput).not.toHaveBeenCalled(); + }); + + it("closeInput sends the message once", () => { + const { handle, sendCloseStdin } = makeHandle(false); + handle.closeInput(); + handle.closeInput(); + expect(sendCloseStdin).toHaveBeenCalledTimes(1); + expect(() => handle.sendInput("data")).toThrow( + LangSmithSandboxOperationError, + ); + }); + + it("closeInput is a no-op under a PTY", () => { + const { handle, sendInput, sendCloseStdin } = makeHandle(false, true); + handle.closeInput(); + expect(sendCloseStdin).not.toHaveBeenCalled(); + handle.sendInput(""); + expect(sendInput).toHaveBeenCalledWith(""); + }); +}); + +describe("filesystem search", () => { + it("glob posts the pattern and parses matches", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ + matches: [ + { + path: "/workspace/app.py", + is_dir: false, + size_bytes: 12, + modified_at: "2026-07-14T00:00:00Z", + }, + ], + truncated: true, + }), + ); + const sandbox = makeSandbox(mockFetch); + + const result = await sandbox.glob("**/*.py", "/workspace", { limit: 200 }); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${DATAPLANE}/glob`); + expect(JSON.parse(init.body as string)).toEqual({ + pattern: "**/*.py", + path: "/workspace", + limit: 200, + }); + expect(result.truncated).toBe(true); + expect(result.matches[0].size_bytes).toBe(12); + }); + + it("ls is the non-recursive glob case", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue(jsonResponse({ matches: [], truncated: false })); + const sandbox = makeSandbox(mockFetch); + + await sandbox.ls("/workspace"); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ + pattern: "*", + path: "/workspace", + }); + }); + + it("grep passes the file filter through", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + jsonResponse({ + matches: [{ path: "/w/app.py", line: 12, text: "# TODO" }], + truncated: false, + }), + ); + const sandbox = makeSandbox(mockFetch); + + const result = await sandbox.grep("TODO", "/w", { glob: "*.py" }); + + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${DATAPLANE}/grep`); + expect(JSON.parse(init.body as string)).toEqual({ + pattern: "TODO", + path: "/w", + glob: "*.py", + }); + expect(result.matches[0].line).toBe(12); + }); +}); + +describe("buildRangeHeader", () => { + it.each([ + [{ start: 0, end: 1023 }, "bytes=0-1023"], + [{ start: 1024 }, "bytes=1024-"], + [{ suffixBytes: 512 }, "bytes=-512"], + ])("renders %p", (options, expected) => { + expect(buildRangeHeader(options)).toBe(expected); + }); + + it.each([ + {}, + { start: 1, suffixBytes: 2 }, + { start: 5, end: 1 }, + { start: -1 }, + { suffixBytes: 0 }, + ])("rejects %p", (options) => { + expect(() => buildRangeHeader(options)).toThrow(LangSmithValidationError); + }); +}); + +describe("ranged reads", () => { + it("reports a 206 as partial, with its offsets", async () => { + const chunk = await fileChunkFromResponse( + new Response("0123", { + status: 206, + headers: { "Content-Range": "bytes 10-13/100", ETag: '"abc"' }, + }), + ); + expect(chunk.partial).toBe(true); + expect(chunk.start).toBe(10); + expect(chunk.total_bytes).toBe(100); + expect(chunk.etag).toBe('"abc"'); + expect(new TextDecoder().decode(chunk.content)).toBe("0123"); + }); + + it("reports a stale If-Range 200 from byte zero", async () => { + const chunk = await fileChunkFromResponse( + new Response("whole", { status: 200, headers: { ETag: '"new"' } }), + ); + expect(chunk.partial).toBe(false); + expect(chunk.start).toBe(0); + expect(chunk.total_bytes).toBe(5); + }); + + it("reports a 304 as unchanged, with no bytes", async () => { + const chunk = await fileChunkFromResponse( + new Response(null, { status: 304, headers: { ETag: '"same"' } }), + ); + expect(chunk.unchanged).toBe(true); + expect(chunk.content.length).toBe(0); + }); + + it("sends the range and validator headers", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + new Response("0123", { + status: 206, + headers: { "Content-Range": "bytes 0-3/100" }, + }), + ); + const sandbox = makeSandbox(mockFetch); + + await sandbox.readRange("/big.bin", { + start: 0, + end: 3, + ifRange: '"abc"', + }); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers.Range).toBe("bytes=0-3"); + expect(headers["If-Range"]).toBe('"abc"'); + }); + + it("surfaces a 416 as an operation error", async () => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockResolvedValue( + new Response("invalid range", { + status: 416, + headers: { "Content-Range": "bytes */100" }, + }), + ); + const sandbox = makeSandbox(mockFetch); + + await expect(sandbox.readRange("/big.bin", { start: 500 })).rejects.toThrow( + "past the end", + ); + }); + + it("stat reads size and validators from the headers", () => { + const stat = fileStatFromResponse( + new Response(null, { + status: 200, + headers: { + "Content-Length": "100", + ETag: '"abc"', + "Content-Type": "application/octet-stream", + }, + }), + ); + expect(stat.size_bytes).toBe(100); + expect(stat.etag).toBe('"abc"'); + expect(stat.content_type).toBe("application/octet-stream"); + }); +}); + +describe("closeInput before the socket binds", () => { + const fakeWs = () => ({ readyState: 1, send: jest.fn() }) as any; + + it("queues the close and sends it on bind", () => { + // A reconnected stream binds lazily; dropping the close would leave the + // command waiting for an EOF that never comes. + const control = new WSStreamControl(); + control.sendCloseStdin(); + + const ws = fakeWs(); + control._bind(ws); + + expect(ws.send).toHaveBeenCalledWith( + JSON.stringify({ type: "close_stdin" }), + ); + }); + + it("sends immediately once bound", () => { + const control = new WSStreamControl(); + const ws = fakeWs(); + control._bind(ws); + control.sendCloseStdin(); + + expect(ws.send).toHaveBeenCalledTimes(1); + }); + + it("drops a close on a finished stream", () => { + const control = new WSStreamControl(); + control._unbind(); + control.sendCloseStdin(); + + const ws = fakeWs(); + control._bind(ws); + expect(ws.send).not.toHaveBeenCalled(); + }); +}); + +describe("reconnect preserves the command's identity", () => { + it("forwards stdinClosed and pty", async () => { + const sandbox = { + reconnect: jest + .fn<(...args: any[]) => Promise>() + .mockResolvedValue({}), + } as any; + const handle = new CommandHandle( + (async function* () {})() as any, + new WSStreamControl(), + sandbox, + { commandId: "cmd-1", stdinClosed: true, pty: true }, + ); + + await handle.reconnect(); + + expect(sandbox.reconnect).toHaveBeenCalledWith("cmd-1", { + stdoutOffset: 0, + stderrOffset: 0, + stdinClosed: true, + pty: true, + }); + }); +}); diff --git a/src/lib/tests/sandbox_service_url.test.ts b/src/lib/tests/sandbox_service_url.test.ts new file mode 100644 index 0000000..f27cc17 --- /dev/null +++ b/src/lib/tests/sandbox_service_url.test.ts @@ -0,0 +1,179 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest, describe, it, expect } from "@jest/globals"; +import { SandboxClient } from "../sandbox/client.js"; +import { + ServiceLoginUrl, + ServiceUrl, + SERVICE_TOKEN_HEADER, +} from "../sandbox/service_url.js"; +import { LangSmithValidationError } from "../sandbox/errors.js"; + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + +const TOKEN_BODY = { + browser_url: "https://box--8000.example.dev/_svc/auth?token=tok", + service_url: "https://box--8000.example.dev/", + token: "tok", + expires_at: "2099-01-01T00:00:00Z", +}; + +const LOGIN_BODY = { + browser_url: "https://l-abc.example.dev/", + service_url: "https://l-abc.example.dev/", + access: "workspace", +}; + +const clientWithMock = (body: unknown = TOKEN_BODY) => { + const mockFetch = jest + .fn<(url: string, init?: RequestInit) => Promise>() + .mockImplementation(() => Promise.resolve(jsonResponse(body))); + const client = new SandboxClient({ + apiEndpoint: "http://test-server:8080", + apiKey: "test-key", + }); + (client as any)._fetch = mockFetch; + return { client, mockFetch }; +}; + +const sentBody = (mockFetch: any, call = 0) => { + const [, init] = mockFetch.mock.calls[call] as [string, RequestInit]; + return JSON.parse(init.body as string); +}; + +describe("serviceUrl token mode", () => { + it("mints a token and sends only the port by default", async () => { + const { client, mockFetch } = clientWithMock(); + const result = await client.serviceUrl("sb", { port: 8000 }); + expect(result).toBeInstanceOf(ServiceUrl); + expect(sentBody(mockFetch)).toEqual({ port: 8000 }); + expect(await (result as ServiceUrl).token()).toBe("tok"); + expect(await (result as ServiceUrl).serviceUrl()).toBe( + "https://box--8000.example.dev/", + ); + }); + + it("forwards expiresInSeconds", async () => { + const { client, mockFetch } = clientWithMock(); + await client.serviceUrl("sb", { port: 8000, expiresInSeconds: 3600 }); + expect(sentBody(mockFetch)).toEqual({ + port: 8000, + expires_in_seconds: 3600, + }); + }); + + it("injects the token header on fetch", async () => { + const { client } = clientWithMock(); + const svc = (await client.serviceUrl("sb", { port: 8000 })) as ServiceUrl; + const spy = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("ok", { status: 200 })); + try { + await svc.fetch("/api/data"); + const [url, init] = spy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("https://box--8000.example.dev/api/data"); + expect(new Headers(init.headers).get(SERVICE_TOKEN_HEADER)).toBe("tok"); + } finally { + spy.mockRestore(); + } + }); + + it("refreshes a token that is past the margin", async () => { + const stale = { + ...TOKEN_BODY, + token: "stale", + expires_at: "2000-01-01T00:00:00Z", + }; + const { client, mockFetch } = clientWithMock(); + (mockFetch as any) + .mockImplementationOnce(() => Promise.resolve(jsonResponse(stale))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse(TOKEN_BODY))); + const svc = (await client.serviceUrl("sb", { port: 8000 })) as ServiceUrl; + expect(await svc.token()).toBe("tok"); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("refreshes without the mint request's signal", async () => { + const stale = { + ...TOKEN_BODY, + token: "stale", + expires_at: "2000-01-01T00:00:00Z", + }; + const { client, mockFetch } = clientWithMock(); + (mockFetch as any) + .mockImplementationOnce(() => Promise.resolve(jsonResponse(stale))) + .mockImplementationOnce(() => Promise.resolve(jsonResponse(TOKEN_BODY))); + const controller = new AbortController(); + const svc = (await client.serviceUrl("sb", { + port: 8000, + expiresInSeconds: 3600, + signal: controller.signal, + })) as ServiceUrl; + controller.abort(); + + expect(await svc.token()).toBe("tok"); + const [, refreshInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(refreshInit.signal).toBeUndefined(); + expect(sentBody(mockFetch, 1)).toEqual({ + port: 8000, + expires_in_seconds: 3600, + }); + }); +}); + +describe("serviceUrl LangSmith login mode", () => { + it.each(["restricted", "workspace"] as const)( + "returns a login URL for access=%s", + async (access) => { + const { client, mockFetch } = clientWithMock({ ...LOGIN_BODY, access }); + const result = await client.serviceUrl("sb", { port: 8000, access }); + expect(result).toBeInstanceOf(ServiceLoginUrl); + expect((result as ServiceLoginUrl).url).toBe( + "https://l-abc.example.dev/", + ); + expect((result as ServiceLoginUrl).access).toBe(access); + expect(sentBody(mockFetch)).toEqual({ port: 8000, access }); + }, + ); + + it("rejects off, which revokes a share rather than granting one", async () => { + const { client, mockFetch } = clientWithMock(); + await expect( + client.serviceUrl("sb", { port: 8000, access: "off" as any }), + ).rejects.toThrow(/"restricted" or "workspace"/); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); + +describe("serviceUrl validation", () => { + it.each([0, 70000, 1.5])("rejects port %p", async (port) => { + const { client, mockFetch } = clientWithMock(); + await expect(client.serviceUrl("sb", { port })).rejects.toThrow( + LangSmithValidationError, + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects an unknown access value", async () => { + const { client, mockFetch } = clientWithMock(); + await expect( + client.serviceUrl("sb", { port: 8000, access: "maybe" as any }), + ).rejects.toThrow(/"restricted" or "workspace"/); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects expiresInSeconds with a login mode", async () => { + const { client, mockFetch } = clientWithMock(); + await expect( + client.serviceUrl("sb", { + port: 8000, + access: "workspace", + expiresInSeconds: 600, + }), + ).rejects.toThrow(/does not expire/); + expect(mockFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tests/sandbox_verify.test.ts b/src/lib/tests/sandbox_verify.test.ts new file mode 100644 index 0000000..23764ae --- /dev/null +++ b/src/lib/tests/sandbox_verify.test.ts @@ -0,0 +1,370 @@ +import { + jest, + describe, + it, + expect, + beforeEach, + afterEach, +} from "@jest/globals"; +import { createHash, randomUUID } from "node:crypto"; +import { SandboxTokenVerifier } from "../sandbox/verify.js"; +import { LangSmithSandboxTokenVerificationError } from "../sandbox/errors.js"; + +const API_URL = "https://api.example.com/api/v1"; +const JWKS_URL = "https://api.example.com/.well-known/jwks.json"; +const APP_URL = "https://app.example.com"; +const SERVICE_HOST = + "0190aaaa-0000-7000-8000-000000000001--8080.svc.example.com"; +const CALLBACK_URL = "https://integrator.example.com/sandbox-callback"; +const KID = "test-kid"; + +const b64url = (data: string | Uint8Array) => + Buffer.from(data).toString("base64url"); + +async function newKey(): Promise { + return (await crypto.subtle.generateKey({ name: "Ed25519" }, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; +} + +async function jwk(pair: CryptoKeyPair, kid: string) { + return { + ...(await crypto.subtle.exportKey("jwk", pair.publicKey)), + kid, + alg: "EdDSA", + use: "sig", + }; +} + +async function sign( + pair: CryptoKeyPair, + claims: Record, + header: Record = { alg: "EdDSA", kid: KID }, +): Promise { + const input = `${b64url(JSON.stringify(header))}.${b64url( + JSON.stringify(claims), + )}`; + const sig = await crypto.subtle.sign( + { name: "Ed25519" }, + pair.privateKey, + new TextEncoder().encode(input), + ); + return `${input}.${b64url(new Uint8Array(sig))}`; +} + +const now = () => Math.floor(Date.now() / 1000); + +const userClaims = (overrides: Record = {}) => ({ + iss: APP_URL, + sub: "user-123", + aud: [SERVICE_HOST], + exp: now() + 600, + iat: now(), + email: "ada@example.com", + name: "Ada", + ...overrides, +}); + +const callbackBody = (overrides: Record = {}) => + JSON.stringify({ + host: "api.github.com", + port: 443, + identity: { + tenant_id: randomUUID(), + sandbox_id: randomUUID(), + organization_id: randomUUID(), + ls_user_id: randomUUID(), + }, + ...overrides, + }); + +const callbackClaims = ( + body: string, + overrides: Record = {}, +) => ({ + iss: APP_URL, + sub: "langsmith-sandbox-callback", + aud: [CALLBACK_URL], + iat: now(), + nbf: now(), + exp: now() + 300, + jti: randomUUID(), + body_sha256: createHash("sha256").update(body).digest("hex"), + ...overrides, +}); + +describe("SandboxTokenVerifier", () => { + let pair: CryptoKeyPair; + let fetchMock: jest.SpiedFunction; + + const serveKeys = (...keys: unknown[]) => + fetchMock.mockImplementation(async () => Response.json({ keys })); + + beforeEach(async () => { + pair = await newKey(); + fetchMock = jest.spyOn(globalThis, "fetch"); + serveKeys(await jwk(pair, KID)); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + const verifier = () => new SandboxTokenVerifier({ apiUrl: API_URL }); + + describe("verifyUserToken", () => { + it("returns the user for a valid token", async () => { + const user = await verifier().verifyUserToken( + await sign(pair, userClaims()), + { + audience: SERVICE_HOST, + issuer: APP_URL, + }, + ); + expect(user.subject).toBe("user-123"); + expect(user.email).toBe("ada@example.com"); + expect(user.name).toBe("Ada"); + expect(fetchMock.mock.calls[0][0]).toBe(JWKS_URL); + }); + + it("accepts a service URL as the audience", async () => { + const user = await verifier().verifyUserToken( + await sign(pair, userClaims()), + { + audience: `https://${SERVICE_HOST}/path`, + }, + ); + expect(user.subject).toBe("user-123"); + }); + + it.each([ + ["wrong audience", { aud: ["other--8080.svc.example.com"] }, {}], + ["expired", { exp: now() - 120 }, {}], + ["bad issuer", {}, { issuer: "https://evil.example.com" }], + ["empty subject", { sub: "" }, {}], + ["missing subject", { sub: undefined }, {}], + ["callback subject", { sub: "langsmith-sandbox-callback" }, {}], + ["future iat", { iat: now() + 600 }, {}], + ])("rejects %s", async (_name, claims, opts) => { + const token = await sign(pair, userClaims(claims)); + await expect( + verifier().verifyUserToken(token, { audience: SERVICE_HOST, ...opts }), + ).rejects.toThrow(LangSmithSandboxTokenVerificationError); + }); + + it("rejects a token signed by another key", async () => { + const token = await sign(await newKey(), userClaims()); + await expect( + verifier().verifyUserToken(token, { audience: SERVICE_HOST }), + ).rejects.toThrow("invalid signature"); + }); + + it("rejects non-EdDSA tokens", async () => { + const token = await sign(pair, userClaims(), { alg: "HS256", kid: KID }); + await expect( + verifier().verifyUserToken(token, { audience: SERVICE_HOST }), + ).rejects.toThrow("algorithm"); + }); + + it("rejects a tampered payload", async () => { + const [h, , s] = (await sign(pair, userClaims())).split("."); + const forged = `${h}.${b64url( + JSON.stringify(userClaims({ sub: "admin" })), + )}.${s}`; + await expect( + verifier().verifyUserToken(forged, { audience: SERVICE_HOST }), + ).rejects.toThrow("invalid signature"); + }); + + it("caches the JWKS", async () => { + const v = verifier(); + for (let i = 0; i < 3; i++) { + await v.verifyUserToken(await sign(pair, userClaims()), { + audience: SERVICE_HOST, + }); + } + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("refetches for a rotated key", async () => { + const v = verifier(); + await v.verifyUserToken(await sign(pair, userClaims()), { + audience: SERVICE_HOST, + }); + const rotated = await newKey(); + serveKeys(await jwk(pair, KID), await jwk(rotated, "new")); + (v as unknown as { fetchedAt: number }).fetchedAt -= 60_000; + const user = await v.verifyUserToken( + await sign(rotated, userClaims(), { alg: "EdDSA", kid: "new" }), + { audience: SERVICE_HOST }, + ); + expect(user.subject).toBe("user-123"); + }); + + it("rejects an unknown kid without refetching inside the throttle window", async () => { + const v = verifier(); + await v.verifyUserToken(await sign(pair, userClaims()), { + audience: SERVICE_HOST, + }); + const token = await sign(pair, userClaims(), { + alg: "EdDSA", + kid: "nope", + }); + await expect( + v.verifyUserToken(token, { audience: SERVICE_HOST }), + ).rejects.toThrow("unknown signing key"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + }); + + describe("JWKS URL", () => { + it.each([ + "http://localhost:1984/.well-known/jwks.json", + "http://127.0.0.1:1984/.well-known/jwks.json", + "http://[::1]:1984/.well-known/jwks.json", + ])("allows http loopback %s", (jwksUrl) => { + expect(() => new SandboxTokenVerifier({ jwksUrl })).not.toThrow(); + }); + + it.each([ + [{ jwksUrl: "http://langsmith.internal/.well-known/jwks.json" }], + [{ apiUrl: "http://langsmith.internal/api/v1" }], + [{ jwksUrl: "http://127.example.com/.well-known/jwks.json" }], + [{ jwksUrl: "ftp://langsmith.internal/jwks.json" }], + ])("rejects insecure %j", (config) => { + expect(() => new SandboxTokenVerifier(config)).toThrow("https"); + }); + + it("allows plain http with allowInsecureJwks", () => { + expect( + () => + new SandboxTokenVerifier({ + jwksUrl: "http://langsmith.internal/.well-known/jwks.json", + allowInsecureJwks: true, + }), + ).not.toThrow(); + }); + }); + + describe("JWKS fetch", () => { + it("shares one fetch across concurrent misses", async () => { + const v = verifier(); + const token = await sign(pair, userClaims()); + const users = await Promise.all( + Array.from({ length: 8 }, () => + v.verifyUserToken(token, { audience: SERVICE_HOST }), + ), + ); + expect(users.every((u) => u.subject === "user-123")).toBe(true); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + ["null", null], + ["keys object", { keys: {} }], + ["array", []], + ])("rejects a malformed JWKS (%s)", async (_name, body) => { + fetchMock.mockImplementation(async () => Response.json(body)); + await expect( + verifier().verifyUserToken(await sign(pair, userClaims()), { + audience: SERVICE_HOST, + }), + ).rejects.toThrow(LangSmithSandboxTokenVerificationError); + }); + + it("skips non-object JWKS entries", async () => { + fetchMock.mockImplementation(async () => + Response.json({ keys: [null, 1, await jwk(pair, KID)] }), + ); + const user = await verifier().verifyUserToken( + await sign(pair, userClaims()), + { audience: SERVICE_HOST }, + ); + expect(user.subject).toBe("user-123"); + }); + }); + + describe("verifyCallback", () => { + it("returns the payload for a valid callback", async () => { + const body = callbackBody(); + const cb = await verifier().verifyCallback({ + body, + signature: await sign(pair, callbackClaims(body)), + aud: CALLBACK_URL, + issuer: APP_URL, + }); + expect(cb.host).toBe("api.github.com"); + expect(cb.port).toBe(443); + expect(cb.identity.sandbox_id).toBe(JSON.parse(body).identity.sandbox_id); + }); + + it("accepts a byte body", async () => { + const body = callbackBody(); + const cb = await verifier().verifyCallback({ + body: new TextEncoder().encode(body), + signature: await sign(pair, callbackClaims(body)), + aud: CALLBACK_URL, + }); + expect(cb.port).toBe(443); + }); + + it("accepts an aud predicate", async () => { + const body = callbackBody(); + const signature = await sign(pair, callbackClaims(body)); + const cb = await verifier().verifyCallback({ + body, + signature, + aud: (a) => a.startsWith("https://integrator.example.com/"), + }); + expect(cb.port).toBe(443); + await expect( + verifier().verifyCallback({ + body, + signature, + aud: (a) => a === "https://x/cb", + }), + ).rejects.toThrow("audience"); + }); + + it("skips the audience check without aud", async () => { + const body = callbackBody(); + const cb = await verifier().verifyCallback({ + body, + signature: await sign( + pair, + callbackClaims(body, { aud: ["https://other.example.com/cb"] }), + ), + }); + expect(cb.port).toBe(443); + }); + + it("rejects a tampered body", async () => { + const body = callbackBody(); + const signature = await sign(pair, callbackClaims(body)); + await expect( + verifier().verifyCallback({ + body: callbackBody({ host: "evil.example.com" }), + signature, + aud: CALLBACK_URL, + }), + ).rejects.toThrow("body does not match"); + }); + + it.each([ + ["wrong aud", { aud: ["https://other.example.com/cb"] }], + ["wrong subject", { sub: "user-123" }], + ["expired", { exp: now() - 120 }], + ["missing body hash", { body_sha256: undefined }], + ])("rejects %s", async (_name, claims) => { + const body = callbackBody(); + await expect( + verifier().verifyCallback({ + body, + signature: await sign(pair, callbackClaims(body, claims)), + aud: CALLBACK_URL, + }), + ).rejects.toThrow(LangSmithSandboxTokenVerificationError); + }); + }); +}); diff --git a/src/lib/tests/sandbox_ws_handshake.test.ts b/src/lib/tests/sandbox_ws_handshake.test.ts new file mode 100644 index 0000000..7a28de1 --- /dev/null +++ b/src/lib/tests/sandbox_ws_handshake.test.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from "node:events"; +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { + LangSmithSandboxConnectionError, + LangSmithSandboxRetryableConnectionError, +} from "../sandbox/errors.js"; + +const response = { + statusCode: 503, + headers: {} as Record, + resume: jest.fn(), +}; +const request = { destroy: jest.fn() }; + +class MockWebSocket extends EventEmitter { + readyState = 0; + + constructor() { + super(); + queueMicrotask(() => { + this.emit("unexpected-response", request, response); + }); + } +} + +jest.unstable_mockModule("ws", () => ({ default: MockWebSocket })); + +const { runWsStream } = await import("../sandbox/ws_execute.js"); + +async function rejectedUpgrade(statusCode: number): Promise { + response.statusCode = statusCode; + const [stream] = await runWsStream( + "https://dp.example.com/sb-123", + undefined, + "echo hi", + ); + return stream.next(); +} + +describe("WebSocket upgrade rejection", () => { + beforeEach(() => { + response.headers = {}; + response.resume.mockClear(); + request.destroy.mockClear(); + }); + + it.each([500, 502, 503, 504])( + "treats HTTP %i as retryable", + async (statusCode) => { + await expect(rejectedUpgrade(statusCode)).rejects.toBeInstanceOf( + LangSmithSandboxRetryableConnectionError, + ); + expect(response.resume).toHaveBeenCalledTimes(1); + expect(request.destroy).toHaveBeenCalledTimes(1); + }, + ); + + it("treats HTTP 429 as retryable and preserves Retry-After", async () => { + response.headers = { "retry-after": "10" }; + + const error = await rejectedUpgrade(429).catch( + (rejection: unknown) => rejection, + ); + + expect(error).toBeInstanceOf(LangSmithSandboxRetryableConnectionError); + expect(error).toHaveProperty("retryAfterSeconds", 10); + }); + + it.each(["", "-1", "nan", "Infinity"])( + "ignores unusable Retry-After %p", + async (retryAfter) => { + response.headers = { "retry-after": retryAfter }; + + const error = await rejectedUpgrade(429).catch( + (rejection: unknown) => rejection, + ); + + expect(error).toHaveProperty("retryAfterSeconds", undefined); + }, + ); + + it.each([400, 401, 403, 404, 505])( + "keeps HTTP %i permanent", + async (statusCode) => { + const error = await rejectedUpgrade(statusCode).catch( + (rejection: unknown) => rejection, + ); + expect(error).toBeInstanceOf(LangSmithSandboxConnectionError); + expect(error).not.toBeInstanceOf( + LangSmithSandboxRetryableConnectionError, + ); + }, + ); +}); diff --git a/src/lib/tests/serialize.test.ts b/src/lib/tests/serialize.test.ts new file mode 100644 index 0000000..c88b65f --- /dev/null +++ b/src/lib/tests/serialize.test.ts @@ -0,0 +1,585 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect } from "@jest/globals"; +import mongoose from "mongoose"; +import { + serialize, + estimateSerializedSize, +} from "../utils/fast-safe-stringify/index.js"; +import { hasLargeString } from "../utils/serialize_worker.js"; +import { + HumanMessage, + SystemMessage, + AIMessage, +} from "@langchain/core/messages"; + +describe("serializeWellKnownTypes", () => { + it("should handle Map objects", () => { + const map = new Map([ + ["key1", "value1"], + ["key2", "value2"], + ]); + const obj = { testMap: map }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testMap).toEqual({ key1: "value1", key2: "value2" }); + }); + + it("should handle Set objects", () => { + const set = new Set([1, 2, 3, "hello"]); + const obj = { testSet: set }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testSet).toEqual([1, 2, 3, "hello"]); + }); + + it("should handle Date objects", () => { + const date = new Date("2023-01-01T00:00:00.000Z"); + const obj = { testDate: date }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testDate).toBe("2023-01-01T00:00:00.000Z"); + }); + + it("should handle RegExp objects", () => { + const regex = /test.*pattern/gi; + const obj = { testRegex: regex }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testRegex).toBe("/test.*pattern/gi"); + }); + + it("should handle Error objects", () => { + const error = new Error("Test error message"); + error.name = "TestError"; + const obj = { testError: error }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testError).toEqual({ + name: "TestError", + message: "Test error message", + }); + // Should not include stack trace for security + expect(parsed.testError.stack).toBeUndefined(); + }); + + it("should handle bigint values", () => { + const obj = { testBigint: BigInt(123456789012345678901234567890n) }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.testBigint).toBe("123456789012345678901234567890"); + }); + + it("should handle nested well-known types", () => { + const obj = { + level1: { + map: new Map([["nested", "value"]]), + set: new Set([1, 2]), + date: new Date("2023-01-01T00:00:00.000Z"), + error: new Error("Nested error"), + regex: /nested/g, + bigint: BigInt(123), + }, + }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.level1.map).toEqual({ nested: "value" }); + expect(parsed.level1.set).toEqual([1, 2]); + expect(parsed.level1.date).toBe("2023-01-01T00:00:00.000Z"); + expect(parsed.level1.error).toEqual({ + name: "Error", + message: "Nested error", + }); + expect(parsed.level1.regex).toBe("/nested/g"); + expect(parsed.level1.bigint).toBe("123"); + }); + + it("should handle mixed arrays with well-known types", () => { + const obj = { + mixedArray: [ + new Map([["key", "value"]]), + new Set([1, 2, 3]), + new Date("2023-01-01T00:00:00.000Z"), + new Error("Array error"), + /pattern/i, + BigInt(456), + "regular string", + 42, + ], + }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.mixedArray[0]).toEqual({ key: "value" }); + expect(parsed.mixedArray[1]).toEqual([1, 2, 3]); + expect(parsed.mixedArray[2]).toBe("2023-01-01T00:00:00.000Z"); + expect(parsed.mixedArray[3]).toEqual({ + name: "Error", + message: "Array error", + }); + expect(parsed.mixedArray[4]).toBe("/pattern/i"); + expect(parsed.mixedArray[5]).toBe("456"); + expect(parsed.mixedArray[6]).toBe("regular string"); + expect(parsed.mixedArray[7]).toBe(42); + }); + + it("should not serialize functions", () => { + const obj = { + fn: function testFunction() { + return "test"; + }, + arrow: () => "arrow", + regularProp: "value", + }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + // Functions should remain as-is and be filtered out by JSON.stringify + expect(parsed.fn).toBeUndefined(); + expect(parsed.arrow).toBeUndefined(); + expect(parsed.regularProp).toBe("value"); + }); + + it("should not serialize symbols", () => { + const sym = Symbol("test"); + const obj = { + [sym]: "symbol value", + regularProp: "value", + }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + // Symbols should be filtered out by JSON.stringify + expect(Object.getOwnPropertySymbols(parsed)).toHaveLength(0); + expect(parsed.regularProp).toBe("value"); + }); + + it("should handle complex objects without modification", () => { + class CustomClass { + constructor(public value: string) {} + } + + const obj = { + custom: new CustomClass("test"), + regularProp: "value", + }; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + // Custom classes should remain as-is (just their enumerable properties) + expect(parsed.custom).toEqual({ value: "test" }); + expect(parsed.regularProp).toBe("value"); + }); + + it("should handle circular references", () => { + const obj: any = { prop: "value" }; + obj.circular = obj; + + const result = serialize(obj); + const parsed = JSON.parse(new TextDecoder().decode(result)); + + expect(parsed.prop).toBe("value"); + expect(parsed.circular).toEqual({ result: "[Circular]" }); + }); +}); + +describe("estimateSerializedSize", () => { + // Helper: estimate must be within a tolerance of the real size. + // Direction matters: for soft limits on the ingest queue, over-estimating + // is safer than under-estimating (we may flush slightly earlier than we + // would otherwise). We allow a wide band here because the point of the + // estimator is speed, not precision. + function expectClose( + value: unknown, + opts: { minRatio?: number; maxRatio?: number } = {}, + ) { + const { minRatio = 0.9, maxRatio = 3.0 } = opts; + const real = serialize(value).length; + const estimate = estimateSerializedSize(value).size; + const ratio = estimate / real; + if (ratio < minRatio || ratio > maxRatio) { + throw new Error( + `estimate out of range: real=${real}, estimate=${estimate}, ratio=${ratio.toFixed( + 3, + )} (expected between ${minRatio} and ${maxRatio})`, + ); + } + } + + it("handles shared references without treating them as circular", () => { + // The repeated `shared` object is serialized twice by JSON.stringify, + // so the estimate should count it twice. + const shared = { text: "x".repeat(1000) }; + const payload = { a: shared, b: shared }; + expectClose(payload, { minRatio: 0.95, maxRatio: 1.05 }); + }); + + it("handles deeply shared subtrees (system prompt pattern)", () => { + const systemPrompt = "You are a helpful assistant. ".repeat(100); + const payload = { + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "hi" }, + ], + metadata: { systemPrompt }, + }; + expectClose(payload, { minRatio: 0.95, maxRatio: 1.05 }); + }); + + it("counts Buffer by its JSON representation, not a constant", () => { + const buf = Buffer.alloc(10000, "x"); + expectClose(buf, { minRatio: 0.95, maxRatio: 1.1 }); + }); + + it("prefers Buffer's dedicated sizing path over generic toJSON duck-typing", () => { + const buf = Buffer.alloc(32, 7); + const originalToJSON = buf.toJSON.bind(buf); + let calls = 0; + buf.toJSON = (() => { + calls += 1; + return originalToJSON(); + }) as typeof buf.toJSON; + + const estimate = estimateSerializedSize(buf).size; + expect(estimate).toBeGreaterThan(0); + expect(calls).toBe(0); + }); + + it("counts typed arrays by their (expensive) JSON representation", () => { + // Uint8Array stringifies as {"0":v,"1":v,...}, not [v,v,...]. + const arr = new Uint8Array(1000).fill(5); + expectClose(arr, { minRatio: 0.9, maxRatio: 2.0 }); + }); + + it("counts ArrayBuffer as an empty object", () => { + // ArrayBuffer itself has no enumerable properties -> "{}" + const buf = new ArrayBuffer(1000); + expectClose(buf, { minRatio: 0.5, maxRatio: 1.5 }); + }); + + it("invokes toJSON for custom types", () => { + class Money { + amount: number; + constructor(n: number) { + this.amount = n; + } + toJSON() { + return { amount: this.amount, currency: "USD" }; + } + } + // Small object -> allow looser ratio for constant overheads. + expectClose(new Money(42), { minRatio: 0.5, maxRatio: 3.0 }); + }); + + it("handles real mongoose documents via toJSON", () => { + // Mongoose documents carry extensive internal state ($__, $isNew, + // prototype methods, getters, etc.) but expose toJSON() for + // serialization. The estimator must invoke toJSON() to match + // JSON.stringify semantics. + const UserSchema = new mongoose.Schema({ + name: String, + age: Number, + tags: [String], + nested: { city: String, active: Boolean }, + }); + const User = + mongoose.models.EstUser ?? mongoose.model("EstUser", UserSchema); + + const doc = new User({ + name: "Alice", + age: 30, + tags: ["admin", "beta"], + nested: { city: "SF", active: true }, + }); + + expectClose(doc, { minRatio: 0.95, maxRatio: 1.1 }); + }); + + it("handles real mongoose documents with nested subdocuments and arrays", () => { + const PostSchema = new mongoose.Schema({ + title: String, + body: String, + comments: [{ text: String, votes: Number }], + }); + const Post = + mongoose.models.EstPost ?? mongoose.model("EstPost", PostSchema); + + const post = new Post({ + title: "Hello", + body: "x".repeat(500), + comments: [ + { text: "nice", votes: 3 }, + { text: "ok", votes: 1 }, + ], + }); + + expectClose(post, { minRatio: 0.95, maxRatio: 1.1 }); + }); + + it("falls back to exact serialization if estimation throws", () => { + const originalKeys = Object.keys; + try { + // Simulate an unexpected edge case inside the estimator traversal. + Object.keys = ((obj: object) => { + if ((obj as any).__explode) { + throw new Error("boom"); + } + return originalKeys(obj); + }) as typeof Object.keys; + + const payload = { + ok: true, + nested: { __explode: true, value: "x".repeat(100) }, + }; + + expect(estimateSerializedSize(payload).size).toBe( + serialize(payload).length, + ); + } finally { + Object.keys = originalKeys; + } + }); + + it("falls back to exact serialization if byte-length calculation throws", () => { + if (typeof Buffer === "undefined") { + return; + } + + const originalByteLength = Buffer.byteLength; + try { + Buffer.byteLength = ((str: string, encoding?: BufferEncoding) => { + if (str.includes("explode-byte-length")) { + throw new Error("boom"); + } + return originalByteLength(str, encoding); + }) as typeof Buffer.byteLength; + + const payload = { text: "explode-byte-length" }; + expect(estimateSerializedSize(payload).size).toBe( + serialize(payload).length, + ); + } finally { + Buffer.byteLength = originalByteLength; + } + }); + + it("counts UTF-8 byte length for non-ASCII strings", () => { + // Chinese: each character is 3 UTF-8 bytes but 1 code unit. + expectClose({ text: "你好世界".repeat(100) }); + // Emoji outside BMP: 4 UTF-8 bytes, 2 code units. + expectClose({ text: "🎉".repeat(100) }); + }); + + it("detects real cycles but not shared siblings", () => { + const shared = { x: 1 }; + const cyc: any = { shared, other: shared }; + cyc.self = cyc; + const real = serialize(cyc).length; + const estimate = estimateSerializedSize(cyc).size; + // Should be finite (no infinite recursion), and in the ballpark. + expect(Number.isFinite(estimate)).toBe(true); + expect(estimate).toBeGreaterThan(0); + // Cycle handling uses a placeholder; we just want sanity. + expect(estimate / real).toBeGreaterThan(0.5); + expect(estimate / real).toBeLessThan(3.0); + }); + + it("handles typical traced-run payloads accurately", () => { + const runish = { + id: "abc-123", + name: "test_run", + run_type: "llm", + inputs: { + messages: [{ role: "user", content: "hello " + "x".repeat(1000) }], + }, + extra: { metadata: { model: "gpt-4", temperature: 0.7 } }, + }; + expectClose(runish, { minRatio: 0.95, maxRatio: 1.1 }); + }); + + it("handles well-known types (Map, Set, Date, RegExp, Error)", () => { + expectClose( + new Map([ + ["a", 1], + ["b", 2], + ]), + { + minRatio: 0.5, + maxRatio: 2.0, + }, + ); + expectClose(new Set([1, 2, 3, "hi"]), { minRatio: 0.5, maxRatio: 3.0 }); + expectClose(new Date(), { minRatio: 0.9, maxRatio: 1.2 }); + expectClose(/foo/g, { minRatio: 0.5, maxRatio: 2.0 }); + expectClose(new Error("boom"), { minRatio: 0.5, maxRatio: 3.0 }); + }); + + it("does not throw on BigInt", () => { + expect(() => + estimateSerializedSize({ big: 123456789012345678901234567890n }), + ).not.toThrow(); + }); + + it("treats undefined/function/symbol as dropped in object properties", () => { + const v = { a: 1, b: undefined, c: () => 1, d: Symbol(), e: 2 }; + const real = serialize(v).length; + const estimate = estimateSerializedSize(v).size; + // Dropped keys shouldn't blow up the estimate significantly. + expect(estimate).toBeLessThan(real * 6); + }); + + it("treats undefined/function/symbol as null inside arrays", () => { + // JSON.stringify renders these as "null" in arrays. + const v = [1, undefined, () => 1, Symbol(), 2]; + const real = serialize(v).length; + const estimate = estimateSerializedSize(v).size; + expect(estimate).toBeGreaterThan(real * 0.5); + }); + + it("reports the UTF-8 byte length of the longest string encountered", () => { + const short = "hi"; + const long = "x".repeat(5000); + const payload = { a: short, nested: { b: long, c: "medium".repeat(100) } }; + const { maxStringLen } = estimateSerializedSize(payload); + expect(maxStringLen).toBe(5000); + }); + + it("tracks maxStringLen across arrays and well-known containers", () => { + const big = "y".repeat(2048); + const payload = { + list: ["a", big, "b"], + map: new Map([["k", big]]), + set: new Set(["short", big]), + }; + expect(estimateSerializedSize(payload).maxStringLen).toBe(2048); + }); + + it("reports zero maxStringLen for payloads with no strings", () => { + expect( + estimateSerializedSize({ a: 1, b: [2, 3], c: true }).maxStringLen, + ).toBe(0); + expect(estimateSerializedSize(null).maxStringLen).toBe(0); + expect(estimateSerializedSize(42).maxStringLen).toBe(0); + }); + + it("handles LangChain message objects", () => { + // LangChain message classes have a toJSON() method that returns + // a serializable representation. The estimator must invoke it. + const messages = [ + new SystemMessage("You are a helpful assistant."), + new HumanMessage("What is the capital of France?"), + new AIMessage("The capital of France is Paris."), + ]; + + expectClose({ messages }, { minRatio: 0.95, maxRatio: 1.1 }); + }); + + it("handles LangChain message objects with complex content", () => { + // Multi-modal message with text and image + const message = new HumanMessage({ + content: [ + { type: "text", text: "What's in this image?" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64," + "x".repeat(1000) }, + }, + ], + }); + + expectClose({ message }, { minRatio: 0.95, maxRatio: 1.1 }); + }); +}); + +describe("hasLargeString", () => { + it("returns false for null / undefined / primitives below threshold", () => { + expect(hasLargeString(null)).toBe(false); + expect(hasLargeString(undefined)).toBe(false); + expect(hasLargeString(0)).toBe(false); + expect(hasLargeString("hi")).toBe(false); + expect(hasLargeString(true)).toBe(false); + }); + + it("returns true for a single string at or above threshold", () => { + expect(hasLargeString("x".repeat(100), 100)).toBe(true); + expect(hasLargeString("x".repeat(99), 100)).toBe(false); + }); + + it("finds a large string nested deep inside a payload", () => { + const payload = { + a: 1, + b: [{ c: "short" }, { d: { e: { f: "y".repeat(200) } } }], + }; + expect(hasLargeString(payload, 100)).toBe(true); + expect(hasLargeString(payload, 1000)).toBe(false); + }); + + it("finds a large string inside Map and Set values", () => { + const big = "z".repeat(500); + expect(hasLargeString(new Map([["k", big]]), 100)).toBe(true); + expect(hasLargeString(new Set([big]), 100)).toBe(true); + }); + + it("short-circuits before visiting the entire graph", () => { + // Put the large string at the front; if we didn't short-circuit, we + // would visit many nodes after it. With short-circuit we should find + // it in O(1) nodes regardless of the rest of the graph. + const big = "a".repeat(200); + const huge = Array.from({ length: 100_000 }, (_, i) => ({ i, v: "x" })); + const payload = { first: big, rest: huge }; + // Budget of 5 is more than enough to reach `first` but far too small + // to scan `rest`. If short-circuit works, we should still return true. + expect(hasLargeString(payload, 100, 5)).toBe(true); + }); + + it("respects the node budget and returns false when exhausted", () => { + // Build a wide payload of small strings; no string exceeds threshold. + const wide: Record = {}; + for (let i = 0; i < 1000; i++) wide[`k${i}`] = "tiny"; + // With a small budget and nothing large to find, we bail out early. + expect(hasLargeString(wide, 100, 50)).toBe(false); + // With a generous budget we still return false (correctly). + expect(hasLargeString(wide, 100, 10_000)).toBe(false); + }); + + it("handles cycles without infinite looping", () => { + const a: any = { name: "a" }; + const b: any = { name: "b", other: a }; + a.other = b; + expect(() => hasLargeString(a, 100)).not.toThrow(); + expect(hasLargeString(a, 100)).toBe(false); + b.big = "q".repeat(200); + expect(hasLargeString(a, 100)).toBe(true); + }); + + it("does not iterate into opaque binary containers", () => { + // A Buffer's .toString() is a huge string but its enumerable own + // properties are few; we should not treat its bytes as a string. + const buf = Buffer.alloc(200_000, "x"); + expect(hasLargeString(buf, 100_000)).toBe(false); + expect(hasLargeString(new Uint8Array(200_000), 100_000)).toBe(false); + expect(hasLargeString(new ArrayBuffer(200_000), 100_000)).toBe(false); + }); + + it("skips Date / RegExp / Error without inspection", () => { + expect(hasLargeString(new Date(), 10)).toBe(false); + expect(hasLargeString(/foo/, 10)).toBe(false); + expect(hasLargeString(new Error("boom"), 10)).toBe(false); + }); +}); diff --git a/src/lib/tests/serialize_worker.test.ts b/src/lib/tests/serialize_worker.test.ts new file mode 100644 index 0000000..7cbfce8 --- /dev/null +++ b/src/lib/tests/serialize_worker.test.ts @@ -0,0 +1,124 @@ +/* eslint-disable no-process-env */ +import { SerializeWorker } from "../utils/serialize_worker.js"; +import { serialize } from "../utils/fast-safe-stringify/index.js"; + +describe("SerializeWorker", () => { + let worker: SerializeWorker; + + beforeEach(() => { + worker = new SerializeWorker(); + }); + + afterEach(async () => { + await worker.terminate(); + }); + + async function serializeToText(payload: unknown): Promise { + const bytes = await worker.serialize(payload); + if (bytes === null) { + throw new Error("worker returned null; expected bytes"); + } + return Buffer.from(bytes).toString("utf8"); + } + + test("serializes a plain object to bytes matching JSON.stringify", async () => { + const payload = { a: 1, b: "two", c: [true, false, null] }; + expect(JSON.parse(await serializeToText(payload))).toEqual(payload); + }); + + test("normalizes Date, RegExp, Map, Set, bigint consistently with main-thread serialize", async () => { + const payload = { + date: new Date("2024-01-01T00:00:00.000Z"), + regex: /foo/gi, + map: new Map([ + ["x", 1], + ["y", 2], + ]), + set: new Set(["a", "b"]), + big: BigInt("9007199254740999"), + }; + const parsed = JSON.parse(await serializeToText(payload)); + expect(parsed.date).toBe("2024-01-01T00:00:00.000Z"); + expect(parsed.regex).toBe("/foo/gi"); + expect(parsed.map).toEqual({ x: 1, y: 2 }); + expect(parsed.set).toEqual(["a", "b"]); + expect(parsed.big).toBe("9007199254740999"); + }); + + test("rejects on DataCloneError for unclonable values (functions)", async () => { + const payload = { fn: () => 42 }; + await expect(worker.serialize(payload)).rejects.toThrow(); + }); + + test("handles large base64-heavy payloads (shape matches real images)", async () => { + const img = "x".repeat(500 * 1024); + const payload = { + messages: [ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: `data:image/png;base64,${img}` }, + }, + ], + }, + ], + }; + const parsed = JSON.parse(await serializeToText(payload)); + expect(parsed.messages[0].content[0].image_url.url).toHaveLength( + "data:image/png;base64,".length + img.length, + ); + }); + + test("tolerates circular references by replacing with [Circular]", async () => { + const payload: Record = { a: 1 }; + payload.self = payload; + expect(await serializeToText(payload)).toContain("[Circular]"); + }); + + test("produces byte-identical output to main-thread serialize for well-known types", async () => { + // This test ensures the worker's inlined serialize logic stays in sync + // with the main-thread implementation. If this fails, the worker source + // in serialize_worker.ts needs to be updated to match. + const payloads = [ + // Primitives and simple structures + { a: 1, b: "two", c: [true, false, null] }, + // Well-known types + { + date: new Date("2024-01-01T00:00:00.000Z"), + regex: /foo/gi, + map: new Map([ + ["x", 1], + ["y", 2], + ]), + set: new Set(["a", "b"]), + big: BigInt("9007199254740999"), + }, + // Error objects + { err: new Error("test error") }, + // Nested structures + { + level1: { + level2: { + level3: { data: "deep" }, + arr: [1, 2, 3], + }, + }, + }, + // Large string (triggers worker path in client) + { big: "x".repeat(100_000) }, + ]; + + for (const payload of payloads) { + const workerBytes = await worker.serialize(payload); + if (workerBytes === null) { + // Worker unavailable in this environment; skip comparison + continue; + } + const workerStr = Buffer.from(workerBytes).toString("utf8"); + const mainStr = Buffer.from(serialize(payload)).toString("utf8"); + expect(workerStr).toBe(mainStr); + } + }); +}); diff --git a/src/lib/tests/test_data/anthropic_managed_builtin_tools.jsonl b/src/lib/tests/test_data/anthropic_managed_builtin_tools.jsonl new file mode 100644 index 0000000..6b0fe46 --- /dev/null +++ b/src/lib/tests/test_data/anthropic_managed_builtin_tools.jsonl @@ -0,0 +1,15 @@ + +["client.beta.sessions.create",{"agent":{"description":null,"id":"agent_01DZW9FndjJBovCG7jRRi1BJ","mcp_servers":[],"model":{"id":"claude-sonnet-5","speed":"standard"},"multiagent":null,"name":"Coding Assistant","skills":[],"system":"You are a helpful coding assistant. Write clean, well-documented code.","tools":[{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_allow"}},"type":"agent_toolset_20260401"}],"type":"agent","version":1},"archived_at":null,"created_at":"2026-07-06T16:26:33.279196Z","environment_id":"env_01UMypGXtnBJKDNYqsCaFv6g","id":"sesn_01UunETy25dNCWyPpkXe27t5","metadata":{},"outcome_evaluations":[],"resources":[],"stats":{"active_seconds":0,"duration_seconds":0},"status":"idle","title":"Quickstart session","type":"session","updated_at":"2026-07-06T16:26:33.279196Z","usage":{"cache_creation":null,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0},"vault_ids":[]}] +["client.beta.sessions.events.send",["sesn_01UunETy25dNCWyPpkXe27t5",{"events":[{"type":"user.message","content":[{"type":"text","text":"Call 'date' to get the current date time"}]}]}]] +["client.beta.sessions.events.stream",{"id":"sevt_01LFdQereQhv54Lrouh3eYEi","processed_at":"2026-07-06T16:26:34.366420Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_01HJkz1JPgfrNCtNHY4iwB3m","processed_at":"2026-07-06T16:26:34.366421Z","session_thread_id":"sthr_019EELAZnKFJUYxagbUMmE52","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"Call 'date' to get the current date time","type":"text"}],"id":"sevt_01UqJJ1xdAkvfXKKpgA3u4dG","processed_at":"2026-07-06T16:26:34.516296Z","type":"user.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_011eXirEqygeHWjjghuEy9v8","processed_at":"2026-07-06T16:26:34.516297Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"evaluated_permission":"allow","id":"sevt_01Tr69wUpbwgvFPzYJR2kuNu","input":{"command":"date"},"name":"bash","processed_at":"2026-07-06T16:26:35.886686Z","type":"agent.tool_use"}] +["client.beta.sessions.events.stream",{"id":"sevt_01GsuwJH1p7aVGBkzUE9gBAy","is_error":false,"model_request_start_id":"sevt_011eXirEqygeHWjjghuEy9v8","model_usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":7126,"input_tokens":2,"output_tokens":46},"processed_at":"2026-07-06T16:26:35.886687Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"content":[{"text":"Mon Jul 6 16:26:37 UTC 2026\n","type":"text"}],"id":"sevt_0163zFsnvdgsN1jHeCrhoZEF","is_error":false,"processed_at":"2026-07-06T16:26:38.120281Z","tool_use_id":"sevt_01Tr69wUpbwgvFPzYJR2kuNu","type":"agent.tool_result"}] +["client.beta.sessions.events.stream",{"id":"sevt_01RNBAkoXNJ5C6H5ja8MVu9y","processed_at":"2026-07-06T16:26:38.225065Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"content":[{"text":"The current date and time is:\n\n**Monday, July 6, 2026, 16:26:37 UTC**","type":"text"}],"id":"sevt_01BTjZCAgRD7r8FM3Wf9UosQ","processed_at":"2026-07-06T16:26:39.707779Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01RXfh7jnT8NA5dNSxqyvX18","is_error":false,"model_request_start_id":"sevt_01RNBAkoXNJ5C6H5ja8MVu9y","model_usage":{"cache_creation_input_tokens":25,"cache_read_input_tokens":7174,"input_tokens":2,"output_tokens":34},"processed_at":"2026-07-06T16:26:39.707780Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_01LjN6K92vgBtPyCP3PTmHCD","processed_at":"2026-07-06T16:26:39.903785Z","session_thread_id":"sthr_019EELAZnKFJUYxagbUMmE52","stop_reason":{"type":"end_turn"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_0159JmWZ4eBC8WLXPMiE9MFH","processed_at":"2026-07-06T16:26:39.903786Z","stop_reason":{"type":"end_turn"},"type":"session.status_idle"}] diff --git a/src/lib/tests/test_data/anthropic_managed_custom_tools.jsonl b/src/lib/tests/test_data/anthropic_managed_custom_tools.jsonl new file mode 100644 index 0000000..f77b018 --- /dev/null +++ b/src/lib/tests/test_data/anthropic_managed_custom_tools.jsonl @@ -0,0 +1,19 @@ +["client.beta.sessions.create",{"agent":{"description":null,"id":"agent_01FredZJoeHTHi7NCDST8Def","mcp_servers":[],"model":{"id":"claude-sonnet-5","speed":"standard"},"multiagent":null,"name":"Coding Assistant","skills":[],"system":"You are a helpful coding assistant. Write clean, well-documented code.","tools":[{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_allow"}},"type":"agent_toolset_20260401"},{"description":"Get current weather for a location","input_schema":{"properties":{"location":{"description":"City name","type":"string"}},"required":["location"],"type":"object"},"name":"get_weather","type":"custom"}],"type":"agent","version":1},"archived_at":null,"created_at":"2026-07-06T14:16:29.466703Z","environment_id":"env_01NXyTAgoaqAYAQsx9S6TTgv","id":"sesn_014fc5Q6rWwrXD3vWBfPRUes","metadata":{},"outcome_evaluations":[],"resources":[],"stats":{"active_seconds":0,"duration_seconds":0},"status":"idle","title":"Quickstart session","type":"session","updated_at":"2026-07-06T14:16:29.466703Z","usage":{"cache_creation":null,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0},"vault_ids":[]}] +["client.beta.sessions.events.send",["sesn_014fc5Q6rWwrXD3vWBfPRUes",{"events":[{"type":"user.message","content":[{"type":"text","text":"What's the weather in Prague?"}]}]}]] +["client.beta.sessions.events.stream",{"id":"sevt_01LpWxFghbGGXZVjme1TQrQM","processed_at":"2026-07-06T14:16:30.476746Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_0185cj8WerHsXPVcRaLkAoyW","processed_at":"2026-07-06T14:16:30.476747Z","session_thread_id":"sthr_01VoaiEV5NRV7TWe4tZgZPDd","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"What's the weather in Prague?","type":"text"}],"id":"sevt_01212YBhhxJuNCLVndXX8vZ5","processed_at":"2026-07-06T14:16:30.622051Z","type":"user.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01WiRUJqub2hJf9yWdhXXpt6","processed_at":"2026-07-06T14:16:30.622052Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"id":"sevt_018iPB2RYrthWNKugV6ehDxH","input":{"location":"Prague"},"name":"get_weather","processed_at":"2026-07-06T14:16:36.646424Z","type":"agent.custom_tool_use"}] +["client.beta.sessions.events.stream",{"id":"sevt_01HmjtS8HwsCbaTbW3xWK4zr","is_error":false,"model_request_start_id":"sevt_01WiRUJqub2hJf9yWdhXXpt6","model_usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":7201,"input_tokens":2,"output_tokens":50},"processed_at":"2026-07-06T14:16:36.646425Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_01637CRJcdgCzHLBs6eQutih","processed_at":"2026-07-06T14:16:36.799750Z","session_thread_id":"sthr_01VoaiEV5NRV7TWe4tZgZPDd","stop_reason":{"event_ids":["sevt_018iPB2RYrthWNKugV6ehDxH"],"type":"requires_action"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01FuJn4xN41MWTASNCsAU94U","processed_at":"2026-07-06T14:16:36.799751Z","stop_reason":{"event_ids":["sevt_018iPB2RYrthWNKugV6ehDxH"],"type":"requires_action"},"type":"session.status_idle"}] +["client.beta.sessions.events.send",["sesn_014fc5Q6rWwrXD3vWBfPRUes",{"events":[{"type":"user.custom_tool_result","custom_tool_use_id":"sevt_018iPB2RYrthWNKugV6ehDxH","content":[{"type":"text","text":"It's 23°C and sunny in Prague."}]}]}]] +["client.beta.sessions.events.stream",{"content":[{"text":"It's 23°C and sunny in Prague.","type":"text"}],"custom_tool_use_id":"sevt_018iPB2RYrthWNKugV6ehDxH","id":"sevt_01N6qTAumErPqiuFkK6syGod","is_error":false,"processed_at":"2026-07-06T14:16:37.307705Z","type":"user.custom_tool_result"}] +["client.beta.sessions.events.stream",{"id":"sevt_01G4be18SERP3BFF35UQhHp9","processed_at":"2026-07-06T14:16:37.437805Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_019hyEPE5kWAAtA22Xo5SFvS","processed_at":"2026-07-06T14:16:37.437806Z","session_thread_id":"sthr_01VoaiEV5NRV7TWe4tZgZPDd","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"id":"sevt_01D52Uu8kvJUya2ZH1cdRQmB","processed_at":"2026-07-06T14:16:37.570394Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"content":[{"text":"It's currently **23°C and sunny** in Prague — a nice, warm day overall!","type":"text"}],"id":"sevt_01Q8GarQqkPv2TRV9LF6FKxd","processed_at":"2026-07-06T14:16:39.272281Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01WfFraJmbtwkxonzfxjDCRm","is_error":false,"model_request_start_id":"sevt_01D52Uu8kvJUya2ZH1cdRQmB","model_usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":7271,"input_tokens":2,"output_tokens":30},"processed_at":"2026-07-06T14:16:39.272282Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Coding Assistant","id":"sevt_019gvK1CEX61E8cWZcsqECrL","processed_at":"2026-07-06T14:16:39.475091Z","session_thread_id":"sthr_01VoaiEV5NRV7TWe4tZgZPDd","stop_reason":{"type":"end_turn"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01GrAhed5cfcqYDQjLAex9Rv","processed_at":"2026-07-06T14:16:39.475092Z","stop_reason":{"type":"end_turn"},"type":"session.status_idle"}] diff --git a/src/lib/tests/test_data/anthropic_managed_mcp_tools.jsonl b/src/lib/tests/test_data/anthropic_managed_mcp_tools.jsonl new file mode 100644 index 0000000..b45a46b --- /dev/null +++ b/src/lib/tests/test_data/anthropic_managed_mcp_tools.jsonl @@ -0,0 +1,22 @@ +["client.beta.sessions.create",{"agent":{"description":null,"id":"agent_01RwjDJMG2Y8cT2Mm5X4EijX","mcp_servers":[{"name":"exa","type":"url","url":"https://mcp.exa.ai/mcp"}],"model":{"id":"claude-opus-4-8","speed":"standard"},"multiagent":null,"name":"Exa assistant","skills":[],"system":"Uses Exa's MCP to answer questions about the LangSmith SDK.","tools":[{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_allow"}},"type":"agent_toolset_20260401"},{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_ask"}},"mcp_server_name":"exa","type":"mcp_toolset"}],"type":"agent","version":1},"archived_at":null,"created_at":"2026-07-06T16:52:52.501281Z","environment_id":"env_01WLCrBTAYSRDqkKESzErg7S","id":"sesn_01FYL2vWeckHzjuCjjpAudtE","metadata":{},"outcome_evaluations":[],"resources":[],"stats":{"active_seconds":0,"duration_seconds":0},"status":"idle","title":"Quickstart session","type":"session","updated_at":"2026-07-06T16:52:52.501281Z","usage":{"cache_creation":null,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0},"vault_ids":["vlt_011Ccm9k9idpxnvzMvNMKp1j"]}] +["client.beta.sessions.events.send",["sesn_01FYL2vWeckHzjuCjjpAudtE",{"events":[{"type":"user.message","content":[{"type":"text","text":"How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa"}]}]}]] +["client.beta.sessions.events.stream",{"id":"sevt_01XqiQPW71Tu7YT9itaWxaoc","processed_at":"2026-07-06T16:52:53.721814Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_01JYwK4eSgdGCCDzk3qvjytx","processed_at":"2026-07-06T16:52:53.721815Z","session_thread_id":"sthr_01Qy9X6y3oYBTGUwFaQ3yUHN","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa","type":"text"}],"id":"sevt_01WK41AxZW24kBH1dbrpE62M","processed_at":"2026-07-06T16:52:53.862710Z","type":"user.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01Fix3cGyKxRnFQAa9dZ7nNz","processed_at":"2026-07-06T16:52:53.862711Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"id":"sevt_014HBj6wW6nAevcN3Th33mXZ","processed_at":"2026-07-06T16:52:58.309083Z","type":"agent.thinking"}] +["client.beta.sessions.events.stream",{"evaluated_permission":"ask","id":"sevt_01FH8SyMCJy9ABg2tCLTPTmB","input":{"numResults":5,"query":"langchain-ai/langsmith-sdk GitHub repository stars"},"mcp_server_name":"exa","name":"web_search_exa","processed_at":"2026-07-06T16:52:58.959847Z","type":"agent.mcp_tool_use"}] +["client.beta.sessions.events.stream",{"id":"sevt_018BbKejA7kY1zhUTLciN7CR","is_error":false,"model_request_start_id":"sevt_01Fix3cGyKxRnFQAa9dZ7nNz","model_usage":{"cache_creation_input_tokens":7780,"cache_read_input_tokens":0,"input_tokens":2,"output_tokens":151},"processed_at":"2026-07-06T16:52:58.959848Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_01EpNWhZJPAdrnu972fFGg9n","processed_at":"2026-07-06T16:52:59.113191Z","session_thread_id":"sthr_01Qy9X6y3oYBTGUwFaQ3yUHN","stop_reason":{"event_ids":["sevt_01FH8SyMCJy9ABg2tCLTPTmB"],"type":"requires_action"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01J82Hr39qz9thUZHZYhmnGw","processed_at":"2026-07-06T16:52:59.113192Z","stop_reason":{"event_ids":["sevt_01FH8SyMCJy9ABg2tCLTPTmB"],"type":"requires_action"},"type":"session.status_idle"}] +["client.beta.sessions.events.send",["sesn_01FYL2vWeckHzjuCjjpAudtE",{"events":[{"type":"user.tool_confirmation","tool_use_id":"sevt_01FH8SyMCJy9ABg2tCLTPTmB","result":"allow"}]}]] +["client.beta.sessions.events.stream",{"id":"sevt_018ogf6bGGo3AagBafVcskcj","processed_at":"2026-07-06T16:52:59.596789Z","result":"allow","tool_use_id":"sevt_01FH8SyMCJy9ABg2tCLTPTmB","type":"user.tool_confirmation"}] +["client.beta.sessions.events.stream",{"id":"sevt_01Fd47A129AnxY2XRu1C7LG9","processed_at":"2026-07-06T16:52:59.727168Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_014F66fwodWuLtsVnU9zgJv8","processed_at":"2026-07-06T16:52:59.727169Z","session_thread_id":"sthr_01Qy9X6y3oYBTGUwFaQ3yUHN","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"Title: langchain-ai/langsmith-sdk - GitHub\nURL: https://github.com/langchain-ai/langsmith-sdk\nPublished: 2023-05-30T19:03:00.000Z\nAuthor: N/A\nHighlights:\nThis repository contains the Python and Javascript SDK's for interacting with the LangSmith platform. Please see LangSmith Documentation for documentation.\n\n---\n\nTitle: langchain-ai/langsmith-sdk\nURL: https://github.com/langchain-ai/langsmith-sdk?search=1\nPublished: 2023-05-30T19:03:00.000Z\nAuthor: N/A\nHighlights:\n- Stars: 917\n- Forks: 246\n- Watchers: 12\n- Open issues: 176\n- Primary language: Python\n- Languages: Python (58.7%), TypeScript (40.9%), JavaScript (0.3%), Makefile, Batchfile\n- License: MIT License (MIT)\n- Topics: evaluation, language-model, observability\n- Default branch: main\n- Homepage: https://docs.smith.langchain.com/\n- Created: 2023-05-30T19:03:00Z\n- Last push: 2026-06-04T00:28:54Z\n- Contributors: 90 (top: hinthornw, jacoblee93, dqbd, dependabot[bot], angus-langchain, baskaryan, langchain-infra, agola11, madams0013, nfcampos)\n- Releases: 446\n- Latest release: v0.8.9 (2026-06-03T17:55:55Z)\n\n---\n\nTitle: Releases · langchain-ai/langsmith-sdk · GitHub\nURL: https://github.com/langchain-ai/langsmith-sdk/releases\nPublished: N/A\nAuthor: langchain-ai\nHighlights:\n51\n\n- Fork 253\n\n## Release list\n\nJump to release\n\n- v0.9.6\n- v0.9.5\n- v0.9.4\n- v0.9.3\n- v0.9.2\n- v0.9.1\n- v0.9.0\n- v0.8.18\n- v0.8.17\n- v0.8.16\n\nv0.9.6 Latest\n\nLatest\n\nCompare\n\n# Choose a tag to compare\n\nView all tags\n\ngithub-actions released this 02 Jul 11:14\n\nv0.9.6\n\n93361c0\n\nThis commit was created on GitHub.com and signed with GitHub’s verified signature.\n\nGPG key ID: B5690EEEBB952194\n\nVerified\n\nLearn about vigilant mode.\n\n## What's Changed\n\n- feat(integrations): add Pipecat voice tracing integration by Caroline di Vittorio (@carolinedivittorio) in#3112\n- feat(integrations): add LiveKit voice tracing integration by Caroline di Vittorio (@carolinedivittorio) in#3113\n- feat(integrations): add OpenAI Realtime voice tracing integration by Caroline di Vittorio (@carolinedivittorio) in#3114\n- feat(js,python): Adds thread_id to metadata for Claude Agent SDK by Jacob Lee (@jacoblee93) in#3141\n- fix(py): serialize tuple-keyed trace dictionaries by Jacob Lee (@jacoblee93) in#3116\n- fix(py): guard OTEL span cleanup [closes#3080] by Jacob Lee (@jacoblee93) in#3118\n- chore(deps): bump the npm_and_yarn group across 3 directories with 2 updates by Dependabot (@dependabot) [bot] in#3123\n- chore(deps): bump pnpm/action-setup from 6.0.8 to 6.0.9 in the actions-minor-and-patch group by Dependabot (@dependabot) [bot] in#3131\n- chore(deps): bump the actions-major group with 4 updates by Dependabot (@dependabot) [bot] in#3133\n- chore(deps-dev): bump the js-minor-and-patch group in /js with 14 updates by Dependabot (@dependabot) [bot] in#3132\n- chore(deps): bump the py-major group across 1 directory with 3 updates by Dependabot (@dependabot) [bot] in#3138\n- chore(deps): bump the py-minor-and-patch group across 1 directory with 21 updates by Dependabot (@dependabot) [bot] in#3140\n- chore(deps): bump the npm_and_yarn group across 4 directories with 1 update by Dependabot (@dependabot) [bot] in#3148\n- chore(deps): update pydata-sphinx-theme requirement from >=0.18.0 to >=0.19.0 in /python by Dependabot (@dependabot) [bot] in#3137\n- chore(deps-dev): bump the js-major group across 1 directory with 7 updates by Dependabot (@dependabot) [bot] in#3134\n- chore: bump _MIN_BACKEND_VERSION to 0.16.10rc1 by @langtions-bot[bot] in#3147\n- chore: sync langsmith_api by @langtions-bot[bot] in#3129\n- release(js): bump to 0.7.15 by KiewanVillatel in#3150\n- fix: remove duplicate opentelemetry-exporter-gcp entries breaking uv.lock by KiewanVillatel in#3151\n- release(python): bump py version to 0.9.6 by KiewanVillatel in#3149\n\n## New Contributors\n\n- Caroline di Vittorio (@carolinedivittorio) made their first contribution in#3112\n\nFull Changelog: v0.9.5...v0.9.6\n\n### Contributors\n\nKiewanVillatel, jacoblee93, and 2 other contributors\n\nAssets 2\n\nv0.9.5\n\n---\n\nTitle: v0.8.10\nURL: https://github.com/langchain-ai/langsmith-sdk/releases/tag/v0.8.10\nPublished: 2026-06-08T19:25:14.000Z\nAuthor: github-actions[bot]\nHighlights:\n- Repository: langchain-ai/langsmith-sdk | LangSmith Client SDK Implementations | 936 stars | Python\n- Author: [@github-actions[bot]](https://github.com/github-actions[bot])\n- Created: 2026-06-08T19:24:54Z\n- Published: 2026-06-08T19:25:14Z\n\n---\n\nTitle: v0.7.34\nURL: https://github.com/langchain-ai/langsmith-sdk/releases/tag/v0.7.34\nPublished: 2026-04-23T13:51:29.000Z\nAuthor: github-actions[bot]\nHighlights:\n- Repository: langchain-ai/langsmith-sdk | LangSmith Client SDK Implementations | 935 stars | Python\n- Author: [@github-actions[bot]](https://github.com/github-actions[bot])\n- Created: 2026-04-23T13:51:10Z\n- Published: 2026-04-23T13:51:29Z","type":"text"}],"id":"sevt_01AKq9dCV9wW8ocH9qrzz1kH","is_error":false,"mcp_tool_use_id":"sevt_01FH8SyMCJy9ABg2tCLTPTmB","processed_at":"2026-07-06T16:53:01.142916Z","type":"agent.mcp_tool_result"}] +["client.beta.sessions.events.stream",{"id":"sevt_01BGa2o5SQM4m5UnTrWUc1MW","processed_at":"2026-07-06T16:53:01.243288Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"id":"sevt_01JhRh2EVJoC4QD1VdhKVHer","processed_at":"2026-07-06T16:53:04.959742Z","type":"agent.thinking"}] +["client.beta.sessions.events.stream",{"content":[{"text":"The **langchain-ai/langsmith-sdk** repository has approximately **936 stars**.\n\nA few notes on the data, since Exa's cached snapshots were taken at different times:\n- Most recent snapshot (~June 2026): **936 stars**\n- Slightly older snapshots showed 935 and 917 stars\n\nSo the star count is currently sitting around **~936**, and given the steady upward trend, the live number on GitHub may be a touch higher. The repo (LangSmith's Python + JavaScript SDKs) also has ~246–253 forks and 90+ contributors.","type":"text"}],"id":"sevt_01DNqjWAXG3yLh6wfCM2fxbm","processed_at":"2026-07-06T16:53:07.234997Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01A4h5PJBuAiEEs9xRhopE1m","is_error":false,"model_request_start_id":"sevt_01BGa2o5SQM4m5UnTrWUc1MW","model_usage":{"cache_creation_input_tokens":2464,"cache_read_input_tokens":7780,"input_tokens":2,"output_tokens":284},"processed_at":"2026-07-06T16:53:07.234998Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_012LeWUPea4Sq7V7JKDKhHXJ","processed_at":"2026-07-06T16:53:07.425299Z","session_thread_id":"sthr_01Qy9X6y3oYBTGUwFaQ3yUHN","stop_reason":{"type":"end_turn"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01Qk1XV3hqmVZGpZuGr8RYQ5","processed_at":"2026-07-06T16:53:07.425300Z","stop_reason":{"type":"end_turn"},"type":"session.status_idle"}] diff --git a/src/lib/tests/test_data/anthropic_managed_mcp_tools_reject.jsonl b/src/lib/tests/test_data/anthropic_managed_mcp_tools_reject.jsonl new file mode 100644 index 0000000..1610f24 --- /dev/null +++ b/src/lib/tests/test_data/anthropic_managed_mcp_tools_reject.jsonl @@ -0,0 +1,27 @@ +["client.beta.sessions.create",{"agent":{"description":null,"id":"agent_011VoCutXTek1HfptjqV6wjP","mcp_servers":[{"name":"exa","type":"url","url":"https://mcp.exa.ai/mcp"}],"model":{"id":"claude-opus-4-8","speed":"standard"},"multiagent":null,"name":"Exa assistant","skills":[],"system":"Uses Exa's MCP to answer questions about the LangSmith SDK.","tools":[{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_allow"}},"type":"agent_toolset_20260401"},{"configs":[],"default_config":{"enabled":true,"permission_policy":{"type":"always_ask"}},"mcp_server_name":"exa","type":"mcp_toolset"}],"type":"agent","version":1},"archived_at":null,"created_at":"2026-07-06T17:00:43.227979Z","environment_id":"env_016PPeBG95DRFUNkDM3f8CEZ","id":"sesn_01KW5vhbxfVqMHgLH2dWKbJG","metadata":{},"outcome_evaluations":[],"resources":[],"stats":{"active_seconds":0,"duration_seconds":0},"status":"idle","title":"Quickstart session","type":"session","updated_at":"2026-07-06T17:00:43.227979Z","usage":{"cache_creation":null,"cache_read_input_tokens":0,"input_tokens":0,"output_tokens":0},"vault_ids":["vlt_011CcmALrDPntx3zeqoEGuT4"]}] +["client.beta.sessions.events.send",["sesn_01KW5vhbxfVqMHgLH2dWKbJG",{"events":[{"type":"user.message","content":[{"type":"text","text":"How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa"}]}]}]] +["client.beta.sessions.events.stream",{"id":"sevt_0138P2eXmdHowG6555SzufaM","processed_at":"2026-07-06T17:00:44.212414Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_01SYFAKVXvevB4N3pyox7Y91","processed_at":"2026-07-06T17:00:44.212415Z","session_thread_id":"sthr_01NJYdmNyHKjhcbZGJKrrBkp","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa","type":"text"}],"id":"sevt_01RV6RpjKYoNa89cFwJaJL8Z","processed_at":"2026-07-06T17:00:44.358663Z","type":"user.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_01D9XMWSwAAw2kfus87Wq7Uv","processed_at":"2026-07-06T17:00:44.358664Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"content":[{"text":"I'll look that up for you using Exa search.","type":"text"}],"id":"sevt_01M83gXYtEjHX7zNaHELyJo6","processed_at":"2026-07-06T17:00:46.888920Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"evaluated_permission":"ask","id":"sevt_01XVQ9cZq6NMEGhXhXUW677S","input":{"numResults":5,"query":"langchain-ai langsmith-sdk GitHub repository stars"},"mcp_server_name":"exa","name":"web_search_exa","processed_at":"2026-07-06T17:00:46.888921Z","type":"agent.mcp_tool_use"}] +["client.beta.sessions.events.stream",{"id":"sevt_01V5ypZ6pc3Z8NTq2GSrSLLh","is_error":false,"model_request_start_id":"sevt_01D9XMWSwAAw2kfus87Wq7Uv","model_usage":{"cache_creation_input_tokens":7780,"cache_read_input_tokens":0,"input_tokens":2,"output_tokens":116},"processed_at":"2026-07-06T17:00:46.888922Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_01YG1yKUqVX4zkBpEuEtVnfH","processed_at":"2026-07-06T17:00:47.044897Z","session_thread_id":"sthr_01NJYdmNyHKjhcbZGJKrrBkp","stop_reason":{"event_ids":["sevt_01XVQ9cZq6NMEGhXhXUW677S"],"type":"requires_action"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01XZxddQfcu536PTc3Ui12yB","processed_at":"2026-07-06T17:00:47.044898Z","stop_reason":{"event_ids":["sevt_01XVQ9cZq6NMEGhXhXUW677S"],"type":"requires_action"},"type":"session.status_idle"}] +["client.beta.sessions.events.send",["sesn_01KW5vhbxfVqMHgLH2dWKbJG",{"events":[{"type":"user.tool_confirmation","tool_use_id":"sevt_01XVQ9cZq6NMEGhXhXUW677S","result":"deny","deny_message":"This tool is not allowed in this demo."}]}]] +["client.beta.sessions.events.stream",{"deny_message":"This tool is not allowed in this demo.","id":"sevt_0183CYiPgMEJ7k7LwoDMV6Kp","processed_at":"2026-07-06T17:00:47.512922Z","result":"deny","tool_use_id":"sevt_01XVQ9cZq6NMEGhXhXUW677S","type":"user.tool_confirmation"}] +["client.beta.sessions.events.stream",{"id":"sevt_01KcurdhuYp39YVgcQGfpWSF","processed_at":"2026-07-06T17:00:47.643286Z","type":"session.status_running"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_015e5BKu8eTeRvFYmdZnorVa","processed_at":"2026-07-06T17:00:47.643287Z","session_thread_id":"sthr_01NJYdmNyHKjhcbZGJKrrBkp","type":"session.thread_status_running"}] +["client.beta.sessions.events.stream",{"content":[{"text":"Permission to use mcp__exa__web_search_exa has been rejected. Rejection message: This tool is not allowed in this demo.","type":"text"}],"id":"sevt_01XdDvxD42azneLnQNwVfhZN","is_error":true,"mcp_tool_use_id":"sevt_01XVQ9cZq6NMEGhXhXUW677S","processed_at":"2026-07-06T17:00:47.761245Z","type":"agent.mcp_tool_result"}] +["client.beta.sessions.events.stream",{"id":"sevt_01Se69hBW1hjbWPyKDF8F5YY","processed_at":"2026-07-06T17:00:47.871009Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"id":"sevt_01LXcmdERgXcWLVRa8ouY5Z4","processed_at":"2026-07-06T17:00:50.149205Z","type":"agent.thinking"}] +["client.beta.sessions.events.stream",{"content":[{"text":"Let me fetch the GitHub repository page directly.","type":"text"}],"id":"sevt_01H5AJYfjgKSDfs1WYt7xsEP","processed_at":"2026-07-06T17:00:50.845811Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"evaluated_permission":"allow","id":"sevt_019amAkdmetsn2j5brbznqFm","input":{"url":"https://github.com/langchain-ai/langsmith-sdk"},"name":"web_fetch","processed_at":"2026-07-06T17:00:50.845812Z","type":"agent.tool_use"}] +["client.beta.sessions.events.stream",{"id":"sevt_01CJ3EexCXoEKWwCm79Q56PU","is_error":false,"model_request_start_id":"sevt_01Se69hBW1hjbWPyKDF8F5YY","model_usage":{"cache_creation_input_tokens":170,"cache_read_input_tokens":7780,"input_tokens":2,"output_tokens":116},"processed_at":"2026-07-06T17:00:50.845813Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"content":[{"source":{"data":"---\nmeta-analytics-location: /<user-name>/<repo-name>\nmeta-apple-itunes-app: app-id=1477376905, app-argument=https://github.com/langchain-ai/langsmith-sdk\nmeta-browser-errors-url: https://api.github.com/_private/browser/errors\nmeta-browser-stats-url: https://api.github.com/_private/browser/stats\nmeta-color-scheme: light dark\nmeta-current-catalog-service-hash: f3abb0cc802f3d7b95fc8762b94bdcb13bf39634c40c357301c4aa1d67a256fb\nmeta-description: LangSmith Client SDK Implementations. Contribute to langchain-ai/langsmith-sdk development by creating an account on GitHub.\nmeta-disable-turbo: false\nmeta-expected-hostname: github.com\nmeta-fb:app_id: 1401488693436528\nmeta-fetch-nonce: v2:e571f139-1761-3c65-0157-c1144baf680c\nmeta-github-keyboard-shortcuts: repository,copilot\nmeta-go-import: github.com/langchain-ai/langsmith-sdk git https://github.com/langchain-ai/langsmith-sdk.git\nmeta-google-site-verification: Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I\nmeta-hostname: github.com\nmeta-hovercard-subject-tag: repository:647427819\nmeta-html-safe-nonce: 302e84594ad4e69f4eeb0f13c3def22a40402f4df2312462b8101350ae28c4c3\nmeta-octolytics-dimension-repository_id: 647427819\nmeta-octolytics-dimension-repository_is_fork: false\nmeta-octolytics-dimension-repository_network_root_id: 647427819\nmeta-octolytics-dimension-repository_network_root_nwo: langchain-ai/langsmith-sdk\nmeta-octolytics-dimension-repository_nwo: langchain-ai/langsmith-sdk\nmeta-octolytics-dimension-repository_public: true\nmeta-octolytics-dimension-user_id: 126733545\nmeta-octolytics-dimension-user_login: langchain-ai\nmeta-octolytics-url: https://collector.github.com/github/collect\nmeta-og:description: LangSmith Client SDK Implementations. Contribute to langchain-ai/langsmith-sdk development by creating an account on GitHub.\nmeta-og:image: https://opengraph.githubassets.com/6ba96529296e954a14fd8636fc45b5f13f35b894df801740eb13896d2ad7f890/langchain-ai/langsmith-sdk\nmeta-og:image:alt: LangSmith Client SDK Implementations. Contribute to langchain-ai/langsmith-sdk development by creating an account on GitHub.\nmeta-og:image:height: 600\nmeta-og:image:width: 1200\nmeta-og:site_name: GitHub\nmeta-og:title: GitHub - langchain-ai/langsmith-sdk: LangSmith Client SDK Implementations\nmeta-og:type: object\nmeta-og:url: https://github.com/langchain-ai/langsmith-sdk\nmeta-release: 772c68a886b50377e1b0b9fe6068cfe1b40a8a45\nmeta-request-id: 04A0:290395:D8122:1192FC:6A0E8116\nmeta-route-action: disambiguate\nmeta-route-controller: files\nmeta-route-pattern: /:user_id/:repository\nmeta-theme-color: #1e2327\nmeta-turbo-body-classes: logged-out env-production page-responsive\nmeta-turbo-cache-control: no-cache\nmeta-twitter:card: summary_large_image\nmeta-twitter:description: LangSmith Client SDK Implementations. Contribute to langchain-ai/langsmith-sdk development by creating an account on GitHub.\nmeta-twitter:image: https://opengraph.githubassets.com/6ba96529296e954a14fd8636fc45b5f13f35b894df801740eb13896d2ad7f890/langchain-ai/langsmith-sdk\nmeta-twitter:site: @github\nmeta-twitter:title: GitHub - langchain-ai/langsmith-sdk: LangSmith Client SDK Implementations\nmeta-ui-target: full\nmeta-user-login:\nmeta-viewport: width=device-width\nmeta-visitor-hmac: 0065a5a3f229a75e15b5a110a8cb0b9a9a262269085f622ee76ddf2399cb02cd\nmeta-visitor-payload: eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiIwNEEwOjI5MDM5NTpEODEyMjoxMTkyRkM6NkEwRTgxMTYiLCJ2aXNpdG9yX2lkIjoiMjU5ODU1NjkyNDk3OTg3MjAyMiIsInJlZ2lvbl9lZGdlIjoiaWFkIiwicmVnaW9uX3JlbmRlciI6ImlhZCJ9\ntitle: GitHub - langchain-ai/langsmith-sdk: LangSmith Client SDK Implementations · GitHub\n---\n\n\n[Skip to content](#start-of-content)\n\n\n## Navigation Menu\n\nToggle navigation\n\n\n\n\n\n[/](https://github.com/)\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Flangchain-ai%2Flangsmith-sdk)\n\n \n\nAppearance settings\n\n- Platform\n\n\n * AI CODE CREATION\n + [GitHub CopilotWrite better code with AI](https://github.com/features/copilot)\n + [GitHub SparkBuild and deploy intelligent apps](https://github.com/features/spark)\n + [GitHub ModelsManage and compare prompts](https://github.com/features/models)\n + [MCP RegistryNewIntegrate external tools](https://github.com/mcp)\n\n * DEVELOPER WORKFLOWS\n + [ActionsAutomate any workflow](https://github.com/features/actions)\n + [CodespacesInstant dev environments](https://github.com/features/codespaces)\n + [IssuesPlan and track work](https://github.com/features/issues)\n + [Code ReviewManage code changes](https://github.com/features/code-review)\n\n * APPLICATION SECURITY\n + [GitHub Advanced SecurityFind and fix vulnerabilities](https://github.com/security/advanced-security)\n + [Code securitySecure your code as you build](https://github.com/security/advanced-security/code-security)\n + [Secret protectionStop leaks before they start](https://github.com/security/advanced-security/secret-protection)\n\n * EXPLORE\n + [Why GitHub](https://github.com/why-github)\n + [Documentation](https://docs.github.com)\n + [Blog](https://github.blog)\n + [Changelog](https://github.blog/changelog)\n + [Marketplace](https://github.com/marketplace)\n\n [View all features](https://github.com/features)\n\n- Solutions\n\n\n * BY COMPANY SIZE\n + [Enterprises](https://github.com/enterprise)\n + [Small and medium teams](https://github.com/team)\n + [Startups](https://github.com/enterprise/startups)\n + [Nonprofits](https://github.com/solutions/industry/nonprofits)\n\n * BY USE CASE\n + [App Modernization](https://github.com/solutions/use-case/app-modernization)\n + [DevSecOps](https://github.com/solutions/use-case/devsecops)\n + [DevOps](https://github.com/solutions/use-case/devops)\n + [CI/CD](https://github.com/solutions/use-case/ci-cd)\n + [View all use cases](https://github.com/solutions/use-case)\n\n * BY INDUSTRY\n + [Healthcare](https://github.com/solutions/industry/healthcare)\n + [Financial services](https://github.com/solutions/industry/financial-services)\n + [Manufacturing](https://github.com/solutions/industry/manufacturing)\n + [Government](https://github.com/solutions/industry/government)\n + [View all industries](https://github.com/solutions/industry)\n\n [View all solutions](https://github.com/solutions)\n\n- Resources\n\n\n * EXPLORE BY TOPIC\n + [AI](https://github.com/resources/articles?topic=ai)\n + [Software Development](https://github.com/resources/articles?topic=software-development)\n + [DevOps](https://github.com/resources/articles?topic=devops)\n + [Security](https://github.com/resources/articles?topic=security)\n + [View all topics](https://github.com/resources/articles)\n\n * EXPLORE BY TYPE\n + [Customer stories](https://github.com/customer-stories)\n + [Events & webinars](https://github.com/resources/events)\n + [Ebooks & reports](https://github.com/resources/whitepapers)\n + [Business insights](https://github.com/solutions/executive-insights)\n + [GitHub Skills](https://skills.github.com)\n\n * SUPPORT & SERVICES\n + [Documentation](https://docs.github.com)\n + [Customer support](https://support.github.com)\n + [Community forum](https://github.com/orgs/community/discussions)\n + [Trust center](https://github.com/trust-center)\n + [Partners](https://github.com/partners)\n\n [View all resources](https://github.com/resources)\n\n- Open Source\n\n\n * COMMUNITY\n + [GitHub SponsorsFund open source developers](https://github.com/sponsors)\n\n * PROGRAMS\n + [Security Lab](https://securitylab.github.com)\n + [Maintainer Community](https://maintainers.github.com)\n + [Accelerator](https://github.com/accelerator)\n + [GitHub Stars](https://stars.github.com)\n + [Archive Program](https://archiveprogram.github.com)\n\n * REPOSITORIES\n + [Topics](https://github.com/topics)\n + [Trending](https://github.com/trending)\n + [Collections](https://github.com/collections)\n\n- Enterprise\n\n\n * ENTERPRISE SOLUTIONS\n + [Enterprise platformAI-powered developer platform](https://github.com/enterprise)\n\n * AVAILABLE ADD-ONS\n + [GitHub Advanced SecurityEnterprise-grade security features](https://github.com/security/advanced-security)\n + [Copilot for BusinessEnterprise-grade AI features](https://github.com/features/copilot/copilot-business)\n + [Premium SupportEnterprise-grade 24/7 support](https://github.com/premium-support)\n\n- [Pricing](https://github.com/pricing)\n\nSearch or jump to...\n\n\n\n# Search code, repositories, users, issues, pull requests...\n\n \n\n[Search syntax tips](https://docs.github.com/search-github/github-code-search/understanding-github-code-search-syntax)\n\n# Provide feedback\n\n \n\nWe read every piece of feedback, and take your input very seriously.\n\nInclude my email address so I can be contacted\n\n Cancel\n\n\nSubmit feedback\n\n# Saved searches\n\n## Use saved searches to filter your results more quickly\n\n\n\nName\n\nQuery\n\nTo see all available qualifiers, see our [documentation](https://docs.github.com/search-github/github-code-search/understanding-github-code-search-syntax).\n\n Cancel\n\n\nCreate saved search\n\n[Sign in](https://github.com/login?return_to=https%3A%2F%2Fgithub.com%2Flangchain-ai%2Flangsmith-sdk)\n\n[Sign up](https://github.com/signup?ref_cta=Sign+up&ref_loc=header+logged+out&ref_page=%2F%3Cuser-name%3E%2F%3Crepo-name%3E&source=header-repo&source_repo=langchain-ai%2Flangsmith-sdk)\n\n \n\nAppearance settings\n\nResetting focus\n\n[langchain-ai](https://github.com/langchain-ai) / **[langsmith-sdk](https://github.com/langchain-ai/langsmith-sdk)** Public\n\n- [Notifications](https://github.com/login?return_to=%2Flangchain-ai%2Flangsmith-sdk) You must be signed in to change notification settings\n- [Fork 234](https://github.com/login?return_to=%2Flangchain-ai%2Flangsmith-sdk)\n- [Star 894](https://github.com/login?return_to=%2Flangchain-ai%2Flangsmith-sdk)\n\n- [Code](https://github.com/langchain-ai/langsmith-sdk)\n- [Issues 112](https://github.com/langchain-ai/langsmith-sdk/issues)\n- [Pull requests 56](https://github.com/langchain-ai/langsmith-sdk/pulls)\n- [Discussions](https://github.com/langchain-ai/langsmith-sdk/discussions)\n- [Actions](https://github.com/langchain-ai/langsmith-sdk/actions)\n- [Projects](https://github.com/langchain-ai/langsmith-sdk/projects)\n- [Security and quality 4](https://github.com/langchain-ai/langsmith-sdk/security)\n- [Insights](https://github.com/langchain-ai/langsmith-sdk/pulse)\n\n\n \n\nAdditional navigation options\n\n\n\n# langchain-ai/langsmith-sdk\n\n main\n\n\n\n[Branches](https://github.com/langchain-ai/langsmith-sdk/branches)[Tags](https://github.com/langchain-ai/langsmith-sdk/tags)\n\n[/langchain-ai/langsmith-sdk/branches](https://github.com/langchain-ai/langsmith-sdk/branches)[/langchain-ai/langsmith-sdk/tags](https://github.com/langchain-ai/langsmith-sdk/tags)\n\nGo to file\n\nCode\n\nOpen more actions menu\n\n## Folders and files\n\n| Name | | Name | Last commit message | Last commit date |\n| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --- | ------------------------------------------------------------------------------------------------------------------ | ------------------- | ---------------- |\n| Latest commit   History[2,676 Commits](https://github.com/langchain-ai/langsmith-sdk/commits/main/) [/langchain-ai/langsmith-sdk/commits/main/](https://github.com/langchain-ai/langsmith-sdk/commits/main/)2,676 Commits | | |\n| [.github](https://github.com/langchain-ai/langsmith-sdk/tree/main/.github \".github\") | | [.github](https://github.com/langchain-ai/langsmith-sdk/tree/main/.github \".github\") | | |\n| [.vscode](https://github.com/langchain-ai/langsmith-sdk/tree/main/.vscode \".vscode\") | | [.vscode](https://github.com/langchain-ai/langsmith-sdk/tree/main/.vscode \".vscode\") | | |\n| [\\_scripts](https://github.com/langchain-ai/langsmith-sdk/tree/main/_scripts \"_scripts\") | | [\\_scripts](https://github.com/langchain-ai/langsmith-sdk/tree/main/_scripts \"_scripts\") | | |\n| [examples](https://github.com/langchain-ai/langsmith-sdk/tree/main/examples \"examples\") | | [examples](https://github.com/langchain-ai/langsmith-sdk/tree/main/examples \"examples\") | | |\n| [js](https://github.com/langchain-ai/langsmith-sdk/tree/main/js \"js\") | | [js](https://github.com/langchain-ai/langsmith-sdk/tree/main/js \"js\") | | |\n| [openapi](https://github.com/langchain-ai/langsmith-sdk/tree/main/openapi \"openapi\") | | [openapi](https://github.com/langchain-ai/langsmith-sdk/tree/main/openapi \"openapi\") | | |\n| [python](https://github.com/langchain-ai/langsmith-sdk/tree/main/python \"python\") | | [python](https://github.com/langchain-ai/langsmith-sdk/tree/main/python \"python\") | | |\n| [.gitattributes](https://github.com/langchain-ai/langsmith-sdk/blob/main/.gitattributes \".gitattributes\") | | [.gitattributes](https://github.com/langchain-ai/langsmith-sdk/blob/main/.gitattributes \".gitattributes\") | | |\n| [.gitignore](https://github.com/langchain-ai/langsmith-sdk/blob/main/.gitignore \".gitignore\") | | [.gitignore](https://github.com/langchain-ai/langsmith-sdk/blob/main/.gitignore \".gitignore\") | | |\n| [.mlc\\_config.json](https://github.com/langchain-ai/langsmith-sdk/blob/main/.mlc_config.json \".mlc_config.json\") | | [.mlc\\_config.json](https://github.com/langchain-ai/langsmith-sdk/blob/main/.mlc_config.json \".mlc_config.json\") | | |\n| [.pre-commit-config.yaml](https://github.com/langchain-ai/langsmith-sdk/blob/main/.pre-commit-config.yaml \".pre-commit-config.yaml\") | | [.pre-commit-config.yaml](https://github.com/langchain-ai/langsmith-sdk/blob/main/.pre-commit-config.yaml \".pre-commit-config.yaml\") | | |\n| [.readthedocs.yml](https://github.com/langchain-ai/langsmith-sdk/blob/main/.readthedocs.yml \".readthedocs.yml\") | | [.readthedocs.yml](https://github.com/langchain-ai/langsmith-sdk/blob/main/.readthedocs.yml \".readthedocs.yml\") | | |\n| [CONTRIBUTING.md](https://github.com/langchain-ai/langsmith-sdk/blob/main/CONTRIBUTING.md \"CONTRIBUTING.md\") | | [CONTRIBUTING.md](https://github.com/langchain-ai/langsmith-sdk/blob/main/CONTRIBUTING.md \"CONTRIBUTING.md\") | | |\n| [LICENSE](https://github.com/langchain-ai/langsmith-sdk/blob/main/LICENSE \"LICENSE\") | | [LICENSE](https://github.com/langchain-ai/langsmith-sdk/blob/main/LICENSE \"LICENSE\") | | |\n| [Makefile](https://github.com/langchain-ai/langsmith-sdk/blob/main/Makefile \"Makefile\") | | [Makefile](https://github.com/langchain-ai/langsmith-sdk/blob/main/Makefile \"Makefile\") | | |\n| [README.md](https://github.com/langchain-ai/langsmith-sdk/blob/main/README.md \"README.md\") | | [README.md](https://github.com/langchain-ai/langsmith-sdk/blob/main/README.md \"README.md\") | | |\n| View all files | | |\n\n## Repository files navigation\n\n- [README](#)\n- [Code of conduct](#)\n- [Contributing](#)\n- [MIT license](#)\n- [Security](#)\n\n# LangSmith Client SDKs\n\n[#langsmith-client-sdks](#langsmith-client-sdks)\n\n[![Release Notes](https://camo.githubusercontent.com/21123fb85aa7ae2675360d8aecc05b1f269fe9098e6ee1ca8b5e4767816c5d6f/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f72656c656173652f6c616e67636861696e2d61692f6c616e67736d6974682d73646b3f6c6f676f3d707974686f6e)](https://github.com/langchain-ai/langsmith-sdk/releases) [![Python Downloads](https://camo.githubusercontent.com/ab9d8b1d429ddf8c471296595a6d4a4638342ffe4445188a842b2cbcc525d43e/68747470733a2f2f696d672e736869656c64732e696f2f707970692f646d2f6c616e67736d697468)](https://pypi.org/project/langsmith/)\n\n[![NPM Version](https://camo.githubusercontent.com/710facc59f3810ad4abdb266464a80c9d5fbfe2987034283570164891dd35ac5/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f6c616e67736d6974683f6c6f676f3d6e706d)](https://camo.githubusercontent.com/710facc59f3810ad4abdb266464a80c9d5fbfe2987034283570164891dd35ac5/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f762f6c616e67736d6974683f6c6f676f3d6e706d) [![JS Downloads](https://camo.githubusercontent.com/a13246858973096e0047eb95e08ae843c94b793c56cb124710ad829b764103f1/68747470733a2f2f696d672e736869656c64732e696f2f6e706d2f646d2f6c616e67736d697468)](https://www.npmjs.com/package/langsmith)\n\nThis repository contains the Python and Javascript SDK's for interacting with the [LangSmith platform](https://smith.langchain.com/). Please see [LangSmith Documentation](https://docs.smith.langchain.com/) for documentation about using the LangSmith platform and the client SDK.\n\nLangSmith helps your team debug, evaluate, and monitor your language models and intelligent agents. It works\nwith any LLM Application, including a native integration with the [LangChain Python](https://github.com/langchain-ai/langchain) and [LangChain JS](https://github.com/langchain-ai/langchainjs) open source libraries.\n\nLangSmith is developed and maintained by [LangChain](https://langchain.com/), the company behind the LangChain framework.\n\n## Quick Start\n\n[#quick-start](#quick-start)\n\nTo get started with the Python SDK, [install the package](https://pypi.org/project/langsmith/), then follow the instructions in the [Python README](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/README.md).\n\n```\npip install -U langsmith\nexport LANGSMITH_TRACING=true\nexport LANGSMITH_API_KEY=ls_...\nexport LANGSMITH_WORKSPACE_ID= # Required for org-scoped keys\n```\n\nThen start tracing your app:\n\n```\nimport openai\nfrom langsmith import traceable\nfrom langsmith.wrappers import wrap_openai\n\nclient = wrap_openai(openai.Client())\n\nclient.chat.completions.create(\n messages=[{\"role\": \"user\", \"content\": \"Hello, world\"}],\n model=\"gpt-3.5-turbo\"\n)\n```\n\nTo get started with the JavaScript / TypeScript SDK, [install the package](https://www.npmjs.com/package/langsmith), then follow the instructions in the [JS README](https://github.com/langchain-ai/langsmith-sdk/blob/main/js/README.md).\n\n```\npnpm add langsmith\nexport LANGSMITH_TRACING=true\nexport LANGSMITH_API_KEY=ls_...\nexport LANGSMITH_WORKSPACE_ID= # Required for org-scoped keys\n```\n\nThen start tracing your app!\n\n```\nimport { OpenAI } from \"openai\";\nimport { traceable } from \"langsmith/traceable\";\nimport { wrapOpenAI } from \"langsmith/wrappers\";\n\nconst client = wrapOpenAI(new OpenAI());\n\nawait client.chat.completions.create({\n model: \"gpt-3.5-turbo\",\n messages: [{ content: \"Hi there!\", role: \"user\" }],\n});\n```\n\n```\n{\n id: 'chatcmpl-8sOWEOYVyehDlyPcBiaDtTxWvr9v6',\n object: 'chat.completion',\n created: 1707974654,\n model: 'gpt-3.5-turbo-0613',\n choices: [\n {\n index: 0,\n message: { role: 'assistant', content: 'Hello! How can I help you today?' },\n logprobs: null,\n finish_reason: 'stop'\n }\n ],\n usage: { prompt_tokens: 10, completion_tokens: 9, total_tokens: 19 },\n system_fingerprint: null\n}\n\n```\n\n## Cookbook\n\n[#cookbook](#cookbook)\n\nFor tutorials on how to get more value out of LangSmith, check out the [Langsmith Cookbook](https://github.com/langchain-ai/langsmith-cookbook/tree/main) repo.\n\n## Documentation\n\n[#documentation](#documentation)\n\nTo learn more about the LangSmith platform, check out the [docs](https://docs.smith.langchain.com/)\n\n## About\n\nLangSmith Client SDK Implementations\n\n[docs.smith.langchain.com/](https://docs.smith.langchain.com/ \"https://docs.smith.langchain.com/\")\n\n### Topics\n\n[evaluation](https://github.com/topics/evaluation \"Topic: evaluation\") [language-model](https://github.com/topics/language-model \"Topic: language-model\") [observability](https://github.com/topics/observability \"Topic: observability\")\n\n### Resources\n\n[Readme](#readme-ov-file)\n\n### License\n\n[MIT license](#MIT-1-ov-file)\n\n### Code of conduct\n\n[Code of conduct](#coc-ov-file)\n\n### Contributing\n\n[Contributing](#contributing-ov-file)\n\n### Security policy\n\n[Security policy](#security-ov-file)\n\n[Activity](https://github.com/langchain-ai/langsmith-sdk/activity)\n\n[Custom properties](https://github.com/langchain-ai/langsmith-sdk/custom-properties)\n\n### Stars\n\n[**894** stars](https://github.com/langchain-ai/langsmith-sdk/stargazers)\n\n### Watchers\n\n[**12** watching](https://github.com/langchain-ai/langsmith-sdk/watchers)\n\n### Forks\n\n[**234** forks](https://github.com/langchain-ai/langsmith-sdk/forks)\n\n[Report repository](https://github.com/contact/report-content?content_url=https%3A%2F%2Fgithub.com%2Flangchain-ai%2Flangsmith-sdk&report=langchain-ai+%28user%29)\n\n## [Releases 442](https://github.com/langchain-ai/langsmith-sdk/releases)\n\n[v0.8.5 Latest May 15, 2026](https://github.com/langchain-ai/langsmith-sdk/releases/tag/v0.8.5)\n\n[+ 441 releases](https://github.com/langchain-ai/langsmith-sdk/releases)\n\n## [Packages](https://github.com/orgs/langchain-ai/packages?repo_name=langsmith-sdk)\n\n \n\n \n\n \n\n## [Contributors](https://github.com/langchain-ai/langsmith-sdk/graphs/contributors)\n\n-\n\n-\n\n-\n\n## Languages\n\n- [Python 58.6%](https://github.com/langchain-ai/langsmith-sdk/search?l=python)\n- [TypeScript 41.0%](https://github.com/langchain-ai/langsmith-sdk/search?l=typescript)\n- Other 0.4%\n\n \n\n## Footer\n\n[https://github.com](https://github.com) © 2026 GitHub, Inc.\n\n### Footer navigation\n\n- [Terms](https://docs.github.com/site-policy/github-terms/github-terms-of-service)\n- [Privacy](https://docs.github.com/site-policy/privacy-policies/github-privacy-statement)\n- [Security](https://github.com/security)\n- [Status](https://www.githubstatus.com/)\n- [Community](https://github.community/)\n- [Docs](https://docs.github.com/)\n- [Contact](https://support.github.com?tags=dotcom-footer)\n- Manage cookies\n\n- Do not share my personal information","media_type":"text/plain","type":"text"},"title":"GitHub - langchain-ai/langsmith-sdk: LangSmith Client SDK Implementations","type":"document"}],"id":"sevt_01FL6QUo9JCbYQFseriev7wS","is_error":false,"processed_at":"2026-07-06T17:00:51.082832Z","tool_use_id":"sevt_019amAkdmetsn2j5brbznqFm","type":"agent.tool_result"}] +["client.beta.sessions.events.stream",{"id":"sevt_01YcV8oywt2voZZDcdTkESD3","processed_at":"2026-07-06T17:00:51.240679Z","type":"span.model_request_start"}] +["client.beta.sessions.events.stream",{"content":[{"text":"The **langchain-ai/langsmith-sdk** repository has **894 stars**.\n\nA few other stats from the repo for context:\n- ⭐ Stars: 894\n- 🍴 Forks: 234\n- 👀 Watchers: 12","type":"text"}],"id":"sevt_01Qo1fqCxqB2GHnjc17BpD5B","processed_at":"2026-07-06T17:00:54.453577Z","type":"agent.message"}] +["client.beta.sessions.events.stream",{"id":"sevt_014Sqf4GExZ9w16cLizX5sC4","is_error":false,"model_request_start_id":"sevt_01YcV8oywt2voZZDcdTkESD3","model_usage":{"cache_creation_input_tokens":10117,"cache_read_input_tokens":7950,"input_tokens":2,"output_tokens":82},"processed_at":"2026-07-06T17:00:54.453578Z","type":"span.model_request_end"}] +["client.beta.sessions.events.stream",{"agent_name":"Exa assistant","id":"sevt_018upT18HYAqDPHuP4hkyYFw","processed_at":"2026-07-06T17:00:54.655408Z","session_thread_id":"sthr_01NJYdmNyHKjhcbZGJKrrBkp","stop_reason":{"type":"end_turn"},"type":"session.thread_status_idle"}] +["client.beta.sessions.events.stream",{"id":"sevt_01JybqJB6zrz9twXEgsdowrb","processed_at":"2026-07-06T17:00:54.655409Z","stop_reason":{"type":"end_turn"},"type":"session.status_idle"}] diff --git a/src/lib/tests/test_data/beemovie.txt b/src/lib/tests/test_data/beemovie.txt new file mode 100644 index 0000000..0413062 --- /dev/null +++ b/src/lib/tests/test_data/beemovie.txt @@ -0,0 +1,1363 @@ +According to all known laws of aviation, there is no way a bee should be able to fly. +Its wings are too small to get its fat little body off the ground. +The bee, of course, flies anyway because bees don't care what humans think is impossible. +Yellow, black. Yellow, black. Yellow, black. Yellow, black. +Ooh, black and yellow! +Let's shake it up a little. +Barry! Breakfast is ready! +Coming! +Hang on a second. +Hello? +Barry? +Adam? +Can you believe this is happening? +I can't. +I'll pick you up. +Looking sharp. +Use the stairs, Your father paid good money for those. +Sorry. I'm excited. +Here's the graduate. +We're very proud of you, son. +A perfect report card, all B's. +Very proud. +Ma! I got a thing going here. +You got lint on your fuzz. +Ow! That's me! +Wave to us! We'll be in row 118,000. +Bye! +Barry, I told you, stop flying in the house! +Hey, Adam. +Hey, Barry. +Is that fuzz gel? +A little. Special day, graduation. +Never thought I'd make it. +Three days grade school, three days high school. +Those were awkward. +Three days college. I'm glad I took a day and hitchhiked around The Hive. +You did come back different. +Hi, Barry. Artie, growing a mustache? Looks good. +Hear about Frankie? +Yeah. +You going to the funeral? +No, I'm not going. +Everybody knows, sting someone, you die. +Don't waste it on a squirrel. +Such a hothead. +I guess he could have just gotten out of the way. +I love this incorporating an amusement park into our day. +That's why we don't need vacations. +Boy, quite a bit of pomp under the circumstances. +Well, Adam, today we are men. +We are! +Bee-men. +Amen! +Hallelujah! +Students, faculty, distinguished bees, +please welcome Dean Buzzwell. +Welcome, New Hive City graduating class of 9:15. +That concludes our ceremonies And begins your career at Honex Industries! +Will we pick our job today? +I heard it's just orientation. +Heads up! Here we go. +Keep your hands and antennas inside the tram at all times. +Wonder what it'll be like? +A little scary. +Welcome to Honex, a division of Honesco and a part of the Hexagon Group. +This is it! +Wow. +Wow. +We know that you, as a bee, have worked your whole life to get to the point where you can work for your whole life. +Honey begins when our valiant Pollen Jocks bring the nectar to The Hive. +Our top-secret formula is automatically color-corrected, scent-adjusted and bubble-contoured into this soothing sweet syrup with its distinctive golden glow you know as... Honey! +That girl was hot. +She's my cousin! +She is? +Yes, we're all cousins. +Right. You're right. +At Honex, we constantly strive to improve every aspect of bee existence. +These bees are stress-testing a new helmet technology. +What do you think he makes? +Not enough. +Here we have our latest advancement, the Krelman. +What does that do? +Catches that little strand of honey that hangs after you pour it. +Saves us millions. +Can anyone work on the Krelman? +Of course. Most bee jobs are small ones. +But bees know that every small job, if it's done well, means a lot. +But choose carefully because you'll stay in the job you pick for the rest of your life. +The same job the rest of your life? I didn't know that. +What's the difference? +You'll be happy to know that bees, as a species, haven't had one day off in 27 million years. +So you'll just work us to death? +We'll sure try. +Wow! That blew my mind! +"What's the difference?" +How can you say that? +One job forever? +That's an insane choice to have to make. +I'm relieved. Now we only have to make one decision in life. +But, Adam, how could they never have told us that? +Why would you question anything? We're bees. +We're the most perfectly functioning society on Earth. +You ever think maybe things work a little too well here? +Like what? Give me one example. +I don't know. But you know what I'm talking about. +Please clear the gate. Royal Nectar Force on approach. +Wait a second. Check it out. +Hey, those are Pollen Jocks! +Wow. +I've never seen them this close. +They know what it's like outside The Hive. +Yeah, but some don't come back. +Hey, Jocks! +Hi, Jocks! +You guys did great! +You're monsters! +You're sky freaks! I love it! I love it! +I wonder where they were. +I don't know. +Their day's not planned. +Outside The Hive, flying who knows where, doing who knows what. +You can't just decide to be a Pollen Jock. You have to be bred for that. +Right. +Look. That's more pollen than you and I will see in a lifetime. +It's just a status symbol. +Bees make too much of it. +Perhaps. Unless you're wearing it and the ladies see you wearing it. +Those ladies? +Aren't they our cousins too? +Distant. Distant. +Look at these two. +Couple of Hive Harrys. +Let's have fun with them. +It must be dangerous being a Pollen Jock. +Yeah. Once a bear pinned me against a mushroom! +He had a paw on my throat, and with the other, he was slapping me! +Oh, my! +I never thought I'd knock him out. +What were you doing during this? +Trying to alert the authorities. +I can autograph that. +A little gusty out there today, wasn't it, comrades? +Yeah. Gusty. +We're hitting a sunflower patch six miles from here tomorrow. +Six miles, huh? +Barry! +A puddle jump for us, but maybe you're not up for it. +Maybe I am. +You are not! +We're going 0900 at J-Gate. +What do you think, buzzy-boy? +Are you bee enough? +I might be. It all depends on what 0900 means. +Hey, Honex! +Dad, you surprised me. +You decide what you're interested in? +Well, there's a lot of choices. +But you only get one. +Do you ever get bored doing the same job every day? +Son, let me tell you about stirring. +You grab that stick, and you just move it around, and you stir it around. +You get yourself into a rhythm. +It's a beautiful thing. +You know, Dad, the more I think about it, +maybe the honey field just isn't right for me. +You were thinking of what, making balloon animals? +That's a bad job for a guy with a stinger. +Janet, your son's not sure he wants to go into honey! +Barry, you are so funny sometimes. +I'm not trying to be funny. +You're not funny! You're going into honey. Our son, the stirrer! +You're gonna be a stirrer? +No one's listening to me! +Wait till you see the sticks I have. +I could say anything right now. +I'm gonna get an ant tattoo! +Let's open some honey and celebrate! +Maybe I'll pierce my thorax. Shave my antennae. Shack up with a grasshopper. Get a gold tooth and call everybody "dawg"! +I'm so proud. +We're starting work today! +Today's the day. +Come on! All the good jobs will be gone. +Yeah, right. +Pollen counting, stunt bee, pouring, stirrer, front desk, hair removal... +Is it still available? +Hang on. Two left! +One of them's yours! Congratulations! +Step to the side. +What'd you get? +Picking crud out. Stellar! +Wow! +Couple of newbies? +Yes, sir! Our first day! We are ready! +Make your choice. +You want to go first? +No, you go. +Oh, my. What's available? +Restroom attendant's open, not for the reason you think. +Any chance of getting the Krelman? +Sure, you're on. +I'm sorry, the Krelman just closed out. +Wax monkey's always open. +The Krelman opened up again. +What happened? +A bee died. Makes an opening. See? He's dead. Another dead one. +Deady. Deadified. Two more dead. +Dead from the neck up. Dead from the neck down. That's life! +Oh, this is so hard! +Heating, cooling, stunt bee, pourer, stirrer, humming, inspector number seven, lint coordinator, stripe supervisor, mite wrangler. +Barry, what do you think I should... Barry? +Barry! +All right, we've got the sunflower patch in quadrant nine... +What happened to you? +Where are you? +I'm going out. +Out? Out where? +Out there. +Oh, no! +I have to, before I go to work for the rest of my life. +You're gonna die! You're crazy! Hello? +Another call coming in. +If anyone's feeling brave, there's a Korean deli on 83rd that gets their roses today. +Hey, guys. +Look at that. +Isn't that the kid we saw yesterday? +Hold it, son, flight deck's restricted. +It's OK, Lou. We're gonna take him up. +Really? Feeling lucky, are you? +Sign here, here. Just initial that. +Thank you. +OK. +You got a rain advisory today, and as you all know, bees cannot fly in rain. +So be careful. As always, watch your brooms, hockey sticks, dogs, birds, bears and bats. +Also, I got a couple of reports of root beer being poured on us. +Murphy's in a home because of it, babbling like a cicada! +That's awful. +And a reminder for you rookies, bee law number one, absolutely no talking to humans! + All right, launch positions! +Buzz, buzz, buzz, buzz! Buzz, buzz, buzz, buzz! Buzz, buzz, buzz, buzz! +Black and yellow! +Hello! +You ready for this, hot shot? +Yeah. Yeah, bring it on. +Wind, check. +Antennae, check. +Nectar pack, check. +Wings, check. +Stinger, check. +Scared out of my shorts, check. +OK, ladies, +let's move it out! +Pound those petunias, you striped stem-suckers! +All of you, drain those flowers! +Wow! I'm out! +I can't believe I'm out! +So blue. +I feel so fast and free! +Box kite! +Wow! +Flowers! +This is Blue Leader, We have roses visual. +Bring it around 30 degrees and hold. +Roses! +30 degrees, roger. Bringing it around. +Stand to the side, kid. +It's got a bit of a kick. +That is one nectar collector! +Ever see pollination up close? +No, sir. +I pick up some pollen here, sprinkle it over here. Maybe a dash over there, a pinch on that one. +See that? It's a little bit of magic. +That's amazing. Why do we do that? +That's pollen power. More pollen, more flowers, more nectar, more honey for us. +Cool. +I'm picking up a lot of bright yellow, Could be daisies, Don't we need those? +Copy that visual. +Wait. One of these flowers seems to be on the move. +Say again? You're reporting a moving flower? +Affirmative. +That was on the line! +This is the coolest. What is it? +I don't know, but I'm loving this color. +It smells good. +Not like a flower, but I like it. +Yeah, fuzzy. +Chemical-y. +Careful, guys. It's a little grabby. +My sweet lord of bees! +Candy-brain, get off there! +Problem! +Guys! +This could be bad. +Affirmative. +Very close. +Gonna hurt. +Mama's little boy. +You are way out of position, rookie! +Coming in at you like a missile! +Help me! +I don't think these are flowers. +Should we tell him? +I think he knows. +What is this?! +Match point! +You can start packing up, honey, because you're about to eat it! +Yowser! +Gross. +There's a bee in the car! +Do something! +I'm driving! +Hi, bee. +He's back here! +He's going to sting me! +Nobody move. If you don't move, he won't sting you. Freeze! +He blinked! +Spray him, Granny! +What are you doing?! +Wow... the tension level out here is unbelievable. +I gotta get home. +Can't fly in rain. Can't fly in rain. Can't fly in rain. +Mayday! Mayday! Bee going down! +Ken, could you close the window please? +Ken, could you close the window please? +Check out my new resume. I made it into a fold-out brochure. You see? Folds out. +Oh, no. More humans. I don't need this. +What was that? +Maybe this time. This time. This time. This time! This time! This... Drapes! +That is diabolical. +It's fantastic. It's got all my special skills, even my top-ten favorite movies. +What's number one? Star Wars? +Nah, I don't go for that... kind of stuff. +No wonder we shouldn't talk to them. They're out of their minds. +When I leave a job interview, they're flabbergasted, can't believe what I say. +There's the sun. Maybe that's a way out. +I don't remember the sun having a big 75 on it. +I predicted global warming. I could feel it getting hotter. At first I thought it was just me. +Wait! Stop! Bee! +Stand back. These are winter boots. +Wait! +Don't kill him! +You know I'm allergic to them! This thing could kill me! +Why does his life have less value than yours? +Why does his life have any less value than mine? Is that your statement? +I'm just saying all life has value. You don't know what he's capable of feeling. +My brochure! +There you go, little guy. +I'm not scared of him.It's an allergic thing. + Put that on your resume brochure. +My whole face could puff up. +Make it one of your special skills. +Knocking someone out is also a special skill. +Right. Bye, Vanessa. Thanks. +Vanessa, next week? Yogurt night? +Sure, Ken. You know, whatever. +You could put carob chips on there. +Bye. +Supposed to be less calories. +Bye. +I gotta say something. She saved my life. I gotta say something. +All right, here it goes. +Nah. +What would I say? +I could really get in trouble. It's a bee law. You're not supposed to talk to a human. +I can't believe I'm doing this. I've got to. +Oh, I can't do it. Come on! +No. Yes. No. Do it. I can't. +How should I start it? "You like jazz?" No, that's no good. +Here she comes! Speak, you fool! +Hi! +I'm sorry. You're talking. +Yes, I know. +You're talking! +I'm so sorry. +No, it's OK. It's fine. +I know I'm dreaming. But I don't recall going to bed. +Well, I'm sure this is very disconcerting. +This is a bit of a surprise to me. I mean, you're a bee! +I am. And I'm not supposed to be doing this, but they were all trying to kill me. +And if it wasn't for you... I had to thank you. It's just how I was raised. +That was a little weird. I'm talking with a bee. +Yeah. +I'm talking to a bee. And the bee is talking to me! +I just want to say I'm grateful. +I'll leave now. +Wait! How did you learn to do that? +What? +The talking thing. +Same way you did, I guess. "Mama, Dada, honey." You pick it up. +That's very funny. +Yeah. +Bees are funny. If we didn't laugh, we'd cry with what we have to deal with. +Anyway... Can I... get you something? +Like what? +I don't know. I mean... I don't know. Coffee? +I don't want to put you out. +It's no trouble. It takes two minutes. +It's just coffee. +I hate to impose. +Don't be ridiculous! +Actually, I would love a cup. +Hey, you want rum cake? +I shouldn't. +Have some. +No, I can't. +Come on! +I'm trying to lose a couple micrograms. +Where? +These stripes don't help. +You look great! +I don't know if you know anything about fashion. +Are you all right? +No. +He's making the tie in the cab as they're flying up Madison. +He finally gets there. +He runs up the steps into the church. +The wedding is on. +And he says, "Watermelon? +I thought you said Guatemalan. +Why would I marry a watermelon?" +Is that a bee joke? +That's the kind of stuff we do. +Yeah, different. +So, what are you gonna do, Barry? +About work? I don't know. +I want to do my part for The Hive, but I can't do it the way they want. +I know how you feel. +You do? +Sure. +My parents wanted me to be a lawyer or a doctor, but I wanted to be a florist. +Really? +My only interest is flowers. +Our new queen was just elected with that same campaign slogan. +Anyway, if you look... There's my hive right there. See it? +You're in Sheep Meadow! +Yes! I'm right off the Turtle Pond! +No way! I know that area. I lost a toe ring there once. +Why do girls put rings on their toes? +Why not? +It's like putting a hat on your knee. +Maybe I'll try that. +You all right, ma'am? +Oh, yeah. Fine. +Just having two cups of coffee! +Anyway, this has been great. +Thanks for the coffee. +Yeah, it's no trouble. +Sorry I couldn't finish it. If I did, I'd be up the rest of my life. +Are you...? +Can I take a piece of this with me? +Sure! Here, have a crumb. +Thanks! +Yeah. +All right. Well, then... I guess I'll see you around. Or not. +OK, Barry. +And thank you so much again... for before. +Oh, that? That was nothing. +Well, not nothing, but... Anyway... +This can't possibly work. +He's all set to go. +We may as well try it. +OK, Dave, pull the chute. +Sounds amazing. +It was amazing! +It was the scariest, happiest moment of my life. +Humans! I can't believe you were with humans! +Giant, scary humans! +What were they like? +Huge and crazy. They talk crazy. +They eat crazy giant things. +They drive crazy. +Do they try and kill you, like on TV? +Some of them. But some of them don't. +How'd you get back? +Poodle. +You did it, and I'm glad. You saw whatever you wanted to see. +You had your "experience." Now you can pick out yourjob and be normal. +Well... +Well? +Well, I met someone. +You did? Was she Bee-ish? +A wasp?! Your parents will kill you! +No, no, no, not a wasp. +Spider? +I'm not attracted to spiders. +I know it's the hottest thing, with the eight legs and all. I can't get by that face. +So who is she? +She's... human. +No, no. That's a bee law. You wouldn't break a bee law. +Her name's Vanessa. +Oh, boy. +She's so nice. And she's a florist! +Oh, no! You're dating a human florist! +We're not dating. +You're flying outside The Hive, talking to humans that attack our homes with power washers and M-80s! One-eighth a stick of dynamite! +She saved my life! And she understands me. +This is over! +Eat this. +This is not over! What was that? +They call it a crumb. +It was so stingin' stripey! +And that's not what they eat. +That's what falls off what they eat! +You know what a Cinnabon is? +No. +It's bread and cinnamon and frosting. They heat it up... +Sit down! +...really hot! +Listen to me! +We are not them! We're us. +There's us and there's them! +Yes, but who can deny the heart that is yearning? +There's no yearning. Stop yearning. Listen to me! +You have got to start thinking bee, my friend. Thinking bee! +Thinking bee. +Thinking bee. +Thinking bee! Thinking bee! Thinking bee! Thinking bee! +There he is. He's in the pool. +You know what your problem is, Barry? +I gotta start thinking bee? +How much longer will this go on? +It's been three days! Why aren't you working? +I've got a lot of big life decisions to think about. +What life? You have no life! +You have no job. You're barely a bee! +Would it kill you to make a little honey? +Barry, come out. Your father's talking to you. +Martin, would you talk to him? +Barry, I'm talking to you! +You coming? +Got everything? +All set! +Go ahead. I'll catch up. +Don't be too long. +Watch this! +Vanessa! +We're still here. +I told you not to yell at him. +He doesn't respond to yelling! +Then why yell at me? +Because you don't listen! +I'm not listening to this. +Sorry, I've gotta go. +Where are you going? +I'm meeting a friend. +A girl? Is this why you can't decide? +Bye. +I just hope she's Bee-ish. +They have a huge parade of flowers every year in Pasadena? +To be in the Tournament of Roses, that's every florist's dream! +Up on a float, surrounded by flowers, crowds cheering. +A tournament. Do the roses compete in athletic events? +No. All right, I've got one. +How come you don't fly everywhere? +It's exhausting. Why don't you run everywhere? It's faster. +Yeah, OK, I see, I see. +All right, your turn. +TiVo. You can just freeze live TV? That's insane! +You don't have that? +We have Hivo, but it's a disease. It's a horrible, horrible disease. +Oh, my. +Dumb bees! +You must want to sting all those jerks. +We try not to sting. It's usually fatal for us. +So you have to watch your temper. +Very carefully. +You kick a wall, take a walk, write an angry letter and throw it out. Work through it like any emotion: Anger, jealousy, lust. +Oh, my goodness! Are you OK? +Yeah. +What is wrong with you?! +It's a bug. +He's not bothering anybody. +Get out of here, you creep! +What was that? A Pic 'N' Save circular? +Yeah, it was. How did you know? +It felt like about 10 pages. Seventy-five is pretty much our limit. +You've really got that down to a science. +I lost a cousin to Italian Vogue. +I'll bet. +What in the name of Mighty Hercules is this? +How did this get here? cute Bee, Golden Blossom, Ray Liotta Private Select? +Is he that actor? +I never heard of him. +Why is this here? +For people. We eat it. +You don't have enough food of your own? +Well, yes. +How do you get it? +Bees make it. +I know who makes it! And it's hard to make it! +There's heating, cooling, stirring. You need a whole Krelman thing! +It's organic. +It's our-ganic! +It's just honey, Barry. +Just what?! +Bees don't know about this! This is stealing! A lot of stealing! +You've taken our homes, schools,hospitals! This is all we have! +And it's on sale?! I'm getting to the bottom of this. +I'm getting to the bottom of all of this! +Hey, Hector. You almost done? +Almost. +He is here. I sense it. +Well, I guess I'll go home now and just leave this nice honey out, with no one around. +You're busted, box boy! +I knew I heard something. +So you can talk! +I can talk. And now you'll start talking! +Where you getting the sweet stuff? Who's your supplier? +I don't understand. +I thought we were friends. +The last thing we want to do is upset bees! +You're too late! It's ours now! +You, sir, have crossed the wrong sword! +You, sir, will be lunch for my iguana, Ignacio! +Where is the honey coming from? Tell me where! +Honey Farms! It comes from Honey Farms! +Crazy person! +What horrible thing has happened here? +These faces, they never knew what hit them. And now +they're on the road to nowhere! +Just keep still. +What? You're not dead? +Do I look dead? They will wipe anything that moves. Where you headed? +To Honey Farms. I am onto something huge here. +I'm going to Alaska. Moose blood, crazy stuff. Blows your head off! +I'm going to Tacoma. +And you? +He really is dead. +All right. +Uh-oh! +What is that?! +Oh, no! +A wiper! Triple blade! +Triple blade? +Jump on! It's your only chance, bee! +Why does everything have +to be so doggone clean?! +How much do you people need to see?! +Open your eyes! +Stick your head out the window! +From NPR News in Washington, +I'm Carl Kasell. +But don't kill no more bugs! +Bee! +Moose blood guy!! +You hear something? +Like what? +Like tiny screaming. +Turn off the radio. +Whassup, bee boy? +Hey, Blood. +Just a row of honey jars, as far as the eye could see. +Wow! +I assume wherever this truck goes is where they're getting it. I mean, that honey's ours. +Bees hang tight. We're all jammed in. +It's a close community. +Not us, man. We on our own. Every mosquito on his own. +What if you get in trouble? +You a mosquito, you in trouble. Nobody likes us. They just smack. See a mosquito, smack, smack! +At least you're out in the world. You must meet girls. +Mosquito girls try to trade up, get with a moth, dragonfly. Mosquito girl don't want no mosquito. +You got to be kidding me! +Mooseblood's about to leave the building! So long, bee! +Hey, guys! +Mooseblood! +I knew I'd catch y'all down here. +Did you bring your crazy straw? +We throw it in jars, slap a label on it, and it's pretty much pure profit. +What is this place? +A bee's got a brain the size of a pinhead. +They are pinheads! +Pinhead. +Check out the new smoker. +Oh, sweet. That's the one you want. The Thomas 3000! +Smoker? +Ninety puffs a minute, semi-automatic. Twice the nicotine, all the tar. A couple breaths of this knocks them right out. +They make the honey, and we make the money. +"They make the honey, and we make the money"? +Oh, my! +What's going on? Are you OK? +Yeah. It doesn't last too long. +Do you know you're in a fake hive with fake walls? +Our queen was moved here. We had no choice. +This is your queen? That's a man in women's clothes! That's a drag queen! +What is this? +Oh, no! +There's hundreds of them! +Bee honey. +Our honey is being brazenly stolen on a massive scale! +This is worse than anything bears have done! I intend to do something. +Oh, Barry, stop. +Who told you humans are taking our honey? That's a rumor. +Do these look like rumors? +That's a conspiracy theory. These are obviously doctored photos. How did you get mixed up in this? +He's been talking to humans. +What? Talking to humans?! +He has a human girlfriend. And they make out! +Make out? Barry! +We do not. +You wish you could. +Whose side are you on? +The bees! +I dated a cricket once in San Antonio. Those crazy legs kept me up all night. +Barry, this is what you want to do with your life? +I want to do it for all our lives. Nobody works harder than bees! +Dad, I remember you coming home so overworked +your hands were still stirring. You couldn't stop. +I remember that. +What right do they have to our honey? +We live on two cups a year. They put it in lip balm for no reason whatsoever! +Even if it's true, what can one bee do? +Sting them where it really hurts. +In the face! The eye! +That would hurt. +No. +Up the nose? That's a killer. +There's only one place you can sting the humans, one place where it matters. +Hive at Five, The Hive's only full-hour action news source. +No more bee beards! +With Bob Bumble at the anchor desk. Weather with Storm Stinger. Sports with Buzz Larvi. And Jeanette Chung. +Good evening. I'm Bob Bumble. +And I'm Jeanette Ohung. +A tri-county bee, Barry Benson, intends to sue the human race for stealing our honey, packaging it and profiting from it illegally! +Tomorrow night on Bee Larry King, we'll have three former queens here in our studio, discussing their new book, classy Ladies, out this week on Hexagon. +Tonight we're talking to Barry Benson. +Did you ever think, "I'm a kid from The Hive. I can't do this"? +Bees have never been afraid to change the world. +What about Bee Oolumbus? Bee Gandhi? Bejesus? +Where I'm from, we'd never sue humans. +We were thinking of stickball or candy stores. +How old are you? +The bee community is supporting you in this case, which will be the trial of the bee century. +You know, they have a Larry King in the human world too. +It's a common name. Next week... +He looks like you and has a show and suspenders and colored dots... +Next week... +Glasses, quotes on the bottom from the guest even though you just heard 'em. +Bear Week next week! They're scary, hairy and here live. +Always leans forward, pointy shoulders, squinty eyes, very Jewish. +In tennis, you attack at the point of weakness! +It was my grandmother, Ken. She's 81. +Honey, her backhand's a joke! +I'm not gonna take advantage of that? +Quiet, please. +Actual work going on here. +Is that that same bee? +Yes, it is! +I'm helping him sue the human race. +Hello. +Hello, bee. +This is Ken. +Yeah, I remember you. Timberland, size ten and a half. Vibram sole, I believe. +Why does he talk again? +Listen, you better go 'cause we're really busy working. +But it's our yogurt night! +Bye-bye. +Why is yogurt night so difficult?! +You poor thing. You two have been at this for hours! +Yes, and Adam here has been a huge help. +Frosting... +How many sugars? +Just one. I try not to use the competition. +So why are you helping me? +Bees have good qualities. And it takes my mind off the shop. Instead of flowers, people are giving balloon bouquets now. +Those are great, if you're three. +And artificial flowers. +Oh, those just get me psychotic! +Yeah, me too. +Bent stingers, pointless pollination. +Bees must hate those fake things! +Nothing worse than a daffodil that's had work done. +Maybe this could make up for it a little bit. +This lawsuit's a pretty big deal. +I guess. +You sure you want to go through with it? +Am I sure? When I'm done with the humans, they won't be able to say, "Honey, I'm home," without paying a royalty! +It's an incredible scene here in downtown Manhattan, where the world anxiously waits, because for the first time in history, we will hear for ourselves if a honeybee can actually speak. +What have we gotten into here, Barry? +It's pretty big, isn't it? +I can't believe how many humans don't work during the day. +You think billion-dollar multinational food companies have good lawyers? +Everybody needs to stay behind the barricade. +What's the matter? +I don't know, I just got a chill. +Well, if it isn't the bee team. +You boys work on this? +All rise! The Honorable Judge Bumbleton presiding. +All right. Case number 4475, +Superior Court of New York, +Barry Bee Benson v. the Honey Industry is now in session. +Mr. Montgomery, you're representing the five food companies collectively? +A privilege. +Mr. Benson... you're representing all the bees of the world? +I'm kidding. Yes, Your Honor, we're ready to proceed. +Mr. Montgomery, your opening statement, please. +Ladies and gentlemen of the jury, my grandmother was a simple woman. Born on a farm, she believed it was man's divine right to benefit from the bounty of nature God put before us. +If we lived in the topsy-turvy world Mr. Benson imagines, just think of what would it mean. +I would have to negotiate with the silkworm for the elastic in my britches! +Talking bee! +How do we know this isn't some sort of holographic motion-picture-capture Hollywood wizardry? +They could be using laser beams! Robotics! Ventriloquism! Cloning! For all we know, he could be on steroids! +Mr. Benson? +Ladies and gentlemen, there's no trickery here. I'm just an ordinary bee. Honey's pretty important to me. It's important to all bees. We invented it! We make it. And we protect it with our lives. +Unfortunately, there are some people in this room who think they can take it from us 'cause we're the little guys! +I'm hoping that, after this is all over, you'll see how, by taking our honey, you not only take everything we have but everything we are! +I wish he'd dress like that all the time. So nice! +Call your first witness. +So, Mr. Klauss Vanderhayden of Honey Farms, big company you have. +I suppose so. +I see you also own Honeyburton and Honron! +Yes, they provide beekeepers for our farms. +Beekeeper. I find that to be a very disturbing term. +I don't imagine you employ any bee-free-ers, do you? +No. +I couldn't hear you. +No. +No. Because you don't free bees. You keep bees. Not only that, it seems you thought a bear would be an appropriate image for a jar of honey. +They're very lovable creatures. Yogi Bear, Fozzie Bear, Build-A-Bear. +You mean like this? +Bears kill bees! +How'd you like his head crashing through your living room?! Biting into your couch! Spitting out your throw pillows! OK, that's enough. Take him away. +So, Mr. Sting, thank you for being here. Your name intrigues me. Where have I heard it before? +I was with a band called The Police. +But you've never been a police officer, have you? +No, I haven't. +No, you haven't. And so here we have yet another example of bee culture casually stolen by a human for nothing more than a prance-about stage name. +Oh, please. +Have you ever been stung, Mr. Sting? Because I'm feeling a little stung, Sting. Or should I say... Mr. Gordon M. Sumner! +That's not his real name?! You idiots! +Mr. Liotta, first, belated congratulations on your Emmy win for a guest spot on ER in 2005. +Thank you. Thank you. +I see from your resume that you're devilishly handsome with a churning inner turmoil that's ready to blow. +I enjoy what I do. Is that a crime? +Not yet it isn't. But is this what it's come to for you? Exploiting tiny, helpless bees so you don't have to rehearse your part and learn your lines, sir? +Watch it, Benson! I could blow right now! +This isn't a goodfella. +This is a badfella! +Why doesn't someone just step on this creep, and we can all go home?! +Order in this court! +You're all thinking it! +Order! Order, I say! +Say it! +Mr. Liotta, please sit down! +I think it was awfully nice of that bear to pitch in like that. I think the jury's on our side. +Are we doing everything right, legally? +I'm a florist. +Right. Well, here's to a great team. +To a great team! +Well, hello. +Ken! +Hello. +I didn't think you were coming. +No, I was just late I tried to call, but... the battery. +I didn't want all this to go to waste, +so I called Barry. Luckily, he was free. +Oh, that was lucky. +There's a little left. I could heat it up. +Yeah, heat it up, sure, whatever. +So I hear you're quite a tennis player. I'm not much for the game myself. The ball's a little grabby. +That's where I usually sit. Right... there. +Ken, Barry was looking at your resume, and he agreed with me that eating with chopsticks isn't really a special skill. +You think I don't see what you're doing? +I know how hard it is to find the right job. We have that in common. +Do we? +Bees have 100 percent employment, but we do jobs like taking the crud out. +That's just what I was thinking about doing. +Ken, I let Barry borrow your razor for his fuzz. I hope that was all right. +I'm going to drain the old stinger. +Yeah, you do that. +Look at that. +You know, I've just about had it with your little Mind Games. +What's that? +Italian Vogue. +Mamma mia, that's a lot of pages. +A lot of ads. +Remember what Van said, why is your life more valuable than mine? +Funny, I just can't seem to recall that! I think something stinks in here! +I love the smell of flowers. +How do you like the smell of flames?! +Not as much. +Water bug! Not taking sides! +Ken, I'm wearing a Chapstick hat! +This is pathetic! +I've got issues! +Well, well, well, a royal flush! +You're bluffing. +Am I? +Surf's up, dude! +Poo water! +That bowl is gnarly. Except for those dirty yellow rings! +Kenneth! What are you doing?! +You know, I don't even like honey! I don't eat it! +We need to talk! He's just a little bee! +And he happens to be the nicest bee I've met in a long time! +Long time? What are you talking about?! Are there other bugs in your life? + No, but there are other things bugging me in life. And you're one of them! +Fine! Talking bees, no yogurt night... +My nerves are fried from riding on this emotional roller coaster! +Goodbye, Ken. +And for your information, I prefer sugar-free, artificial sweeteners made by man! +I'm sorry about all that. +I know it's got an aftertaste! I like it! +I always felt there was some kind of barrier between Ken and me. I couldn't overcome it. +Oh, well. +Are you OK for the trial? +I believe Mr. Montgomery is about out of ideas. +We would like to call Mr. Barry Benson Bee to the stand. +Good idea! You can really see why he's considered one of the best lawyers... +Yeah. +Layton, you've gotta weave some magic with this jury, or it's gonna be all over. +Don't worry. The only thing I have to do to turn this jury around is to remind them of what they don't like about bees. +You got the tweezers? +Are you allergic? +Only to losing, son. Only to losing. +Mr. Benson Bee, I'll ask you what I think we'd all like to know. +What exactly is your relationship to that woman? +We're friends. +Good friends? +Yes. +How good? Do you live together? +Wait a minute... Are you her little... bedbug? +I've seen a bee documentary or two. From what I understand, doesn't your queen give birth to all the bee children? +Yeah, but... +So those aren't your real parents! +Oh, Barry... +Yes, they are! +Hold me back! +You're an illegitimate bee, aren't you, Benson? +He's denouncing bees! +Don't y'all date your cousins? +Objection! +I'm going to pincushion this guy! +Adam, don't! It's what he wants! +Oh, I'm hit!! Oh, lordy, I am hit! +Order! Order! +The venom! The venom is coursing through my veins! I have been felled by a winged beast of destruction! You see? You can't treat them like equals! They're striped savages! Stinging's the only thing they know! It's their way! +Adam, stay with me. +I can't feel my legs. +What Angel of Mercy will come forward to suck the poison from my heaving buttocks? +I will have order in this court. Order! Order, please! +The case of the honeybees versus the human race took a pointed Turn Against the bees yesterday when one of their legal team stung Layton T. Montgomery. +Hey, buddy. +Hey. +Is there much pain? +Yeah. +I... I blew the whole case, didn't I? +It doesn't matter. What matters is +you're alive. You could have died. +I'd be better off dead. Look at me. +They got it from the cafeteria downstairs, in a tuna sandwich. Look, there's a little celery still on it. +What was it like to sting someone? +I can't explain it. It was all... All adrenaline and then...and then ecstasy! +All right. +You think it was all a trap? +Of course. I'm sorry. I flew us right into this. +What were we thinking? Look at us. We're just a couple of bugs in this world. +What will the humans do to us if they win? +I don't know. +I hear they put the roaches in motels. That doesn't sound so bad. +Adam, they check in, but they don't check out! +Oh, my. +Could you get a nurse to close that window? +Why? +The smoke. +Bees don't smoke. +Right. Bees don't smoke. +Bees don't smoke! +But some bees are smoking. +That's it! That's our case! +It is? It's not over? +Get dressed. I've gotta go somewhere. +Get back to the court and stall. Stall any way you can. +And assuming you've done step correctly, you're ready for the tub. +Mr. Flayman. +Yes? Yes, Your Honor! +Where is the rest of your team? +Well, Your Honor, it's interesting. Bees are trained to fly haphazardly, and as a result, we don't make very good time. +I actually heard a funny story about... +Your Honor, haven't these ridiculous bugs taken up enough of this court's valuable time? How much longer will we allow these absurd shenanigans to go on? +They have presented no compelling evidence to support their charges against my clients, who run legitimate businesses. +I move for a complete dismissal of this entire case! +Mr. Flayman, I'm afraid I'm going to have to consider Mr. Montgomery's motion. +But you can't! We have a terrific case. +Where is your proof? +Where is the evidence? +Show me the smoking gun! +Hold it, Your Honor! +You want a smoking gun? Here is your smoking gun. +What is that? +It's a bee smoker! +What, this? This harmless little contraption? This couldn't hurt a fly, let alone a bee. +Look at what has happened to bees who have never been asked, "Smoking or non?" Is this what nature intended for us? To be forcibly addicted to smoke machines and man-made wooden slat work camps? +Living out our lives as honey slaves to the white man? +What are we gonna do? +He's playing the species card. +Ladies and gentlemen, please, free these bees! +Free the bees! Free the bees! Free the bees! Free the bees! Free the bees! +The court finds in favor of the bees! +Vanessa, we won! +I knew you could do it! High-five! +Sorry. +I'm OK! You know what this means? +All the honey will finally belong to the bees. +Now we won't have to work so hard all the time. +This is an unholy perversion of the balance of nature, Benson. +You'll regret this. +Barry, how much honey is out there? +All right. One at a time. +Barry, who are you wearing? +My sweater is Ralph Lauren, and I have no pants. +What if Montgomery's right? +What do you mean? +We've been living the bee way a long time, 27 million years. +Congratulations on your victory. What will you demand as a settlement? +First, we'll demand a complete shutdown of all bee work camps. +Then we want back the honey that was ours to begin with, every last drop. +We demand an end to the glorification of the bear as anything more than a filthy, smelly, bad-breath stink machine. +We're all aware of what they do in the woods. +Wait for my signal. Take him out. +He'll have nauseous for a few hours, then he'll be fine. +And we will no longer tolerate bee-negative nicknames... +But it's just a prance-about stage name! +...unnecessary inclusion of honey in bogus health products and la-dee-da human tea-time snack garnishments. +Can't breathe. +Bring it in, boys! +Hold it right there! Good. +Tap it. +Mr. Buzzwell, we just passed three cups and there's gallons more coming! +I think we need to shut down! +Shut down? We've never shut down. +Shut down honey production! +Stop making honey! +Turn your key, sir! +What do we do now? +Cannonball! +We're shutting honey production! +Mission abort. +Aborting pollination and nectar detail. +Returning to base. +Adam, you wouldn't believe how much honey was out there. +Oh, yeah? +What's going on? Where is everybody? +Are they out celebrating? +They're home. +They don't know what to do. Laying out, sleeping in. +I heard your Uncle Carl was on his way to San Antonio with a cricket. +At least we got our honey back. +Sometimes I think, so what if humans liked our honey? Who wouldn't? +It's the greatest thing in the world! I was excited to be part of making it. +This was my new desk. This was my new job. I wanted to do it really well. And now... +Now I can't. +I don't understand why they're not happy. +I thought their lives would be better! +They're doing nothing. It's amazing. +Honey really changes people. +You don't have any idea what's going on, do you? +What did you want to show me? +This. +What happened here? +That is not the half of it. +Oh, no. Oh, my. +They're all wilting. +Doesn't look very good, does it? +No. +And whose fault do you think that is? +You know, I'm gonna guess bees. +Bees? +Specifically, me. +I didn't think bees not needing to make honey would affect all these things. +It's not just flowers. Fruits, vegetables, they all need bees. +That's our whole SAT test right there. +Take away produce, that affects the entire animal kingdom. +And then, of course... +The human species? +So if there's no more pollination, it could all just go south here, couldn't it? +I know this is also partly my fault. +How about a suicide pact? +How do we do it? +I'll sting you, you step on me. +That just kills you twice. +Right, right. +Listen, Barry... sorry, but I gotta get going. +I had to open my mouth and talk. +Vanessa? +Vanessa? Why are you leaving? +Where are you going? +To the final Tournament of Roses parade in Pasadena. +They've moved it to this weekend because all the flowers are dying. +It's the Last Chance I'll ever have to see it. +Vanessa, I just wanna say I'm sorry. +I never meant it to turn out like this. +I know. Me neither. +Tournament of Roses. +Roses can't do sports. +Wait a minute. Roses. Roses? +Roses! +Vanessa! +Roses?! +Barry? +Roses are flowers! +Yes, they are. +Flowers, bees, pollen! +I know. +That's why this is the last parade. +Maybe not. +Could you ask him to slow down? +Could you slow down? +Barry! +OK, I made a huge mistake. +This is a total disaster, all my fault. +Yes, it kind of is. +I've ruined the planet. I wanted to help you with the flower shop. I've made it worse. +Actually, it's completely closed down. +I thought maybe you were remodeling. +But I have another idea, and it's greater than my previous ideas combined. +I don't want to hear it! +All right, they have the roses, the roses have the pollen. +I know every bee, plant and flower bud in this park. +All we gotta do is get what they've got back here with what we've got. +Bees. +Park. +Pollen! +Flowers. +Repollination! +Across the nation! +Tournament of Roses, Pasadena, California. +They've got nothing but flowers, floats and cotton candy. +Security will be tight. +I have an idea. +Vanessa Bloome, FTD. +Official floral business. It's real. +Sorry, ma'am. Nice brooch. +Thank you. It was a gift. +Once inside, we just pick the right float. +How about The Princess and the Pea? +I could be the princess, and you could be the pea! +Yes, I got it. +Where should I sit? +What are you? +I believe I'm the pea. +The pea? +It goes under the mattresses. +Not in this fairy tale, sweetheart. +I'm getting the marshal. +You do that! This whole parade is a fiasco! +Let's see what this baby'll do. +Hey, what are you doing?! +Then all we do is blend in with traffic... without arousing suspicion. +Once at the airport, there's no stopping us. +Stop! Security. +You and your insect pack your float? +Yes. +Has it been in your possession the entire time? +Would you remove your shoes? +Remove your stinger. +It's part of me. +I know. Just having some fun. +Enjoy your flight. +Then if we're lucky, we'll have just enough pollen to do the job. +Can you believe how lucky we are? We have just enough pollen to do the job! +I think this is gonna work. +It's got to work. +Attention, passengers, this is Captain Scott. We have a bit of bad weather in New York. It looks like we'll experience a couple hours delay. +Barry, these are cut flowers with no water. They'll never make it. +I gotta get up there and talk to them. +Be careful. +Can I get help with the Sky Mall magazine? I'd like to order the talking inflatable nose and ear hair trimmer. +Captain, I'm in a real situation. +What'd you say, Hal? +Nothing. +Bee! +Don't freak out! My entire species... +What are you doing? +Wait a minute! I'm an attorney! +Who's an attorney? +Don't move. +Oh, Barry. +Good afternoon, passengers. This is your captain. Would a Miss Vanessa Bloome in 24B please report to the cockpit? And please hurry! +What happened here? +There was a DustBuster, a toupee, a life raft exploded. +One's bald, one's in a boat, they're both unconscious! +Is that another bee joke? +No! +No one's flying the plane! +This is JFK control tower, Flight 356. What's your status? +This is Vanessa Bloome. I'm a florist from New York. +Where's the pilot? +He's unconscious, and so is the copilot. +Not good. Does anyone onboard have flight experience? +As a matter of fact, there is. +Who's that? +Barry Benson. +From the honey trial?! Oh, great. +Vanessa, this is nothing more than a big metal bee. +It's got giant wings, huge engines. +I can't fly a plane. +Why not? Isn't John Travolta a pilot? +Yes. +How hard could it be? +Wait, Barry! +We're headed into some lightning. +This is Bob Bumble. We have some late-breaking news from JFK Airport, where a suspenseful scene is developing. +Barry Benson, fresh from his legal victory... +That's Barry! +...is attempting to land a plane, loaded with people, flowers and an incapacitated flight crew. +Flowers?! +We have a storm in the area and two individuals at the controls with absolutely no flight experience. +Just a minute. There's a bee on that plane. +I'm quite familiar with Mr. Benson and his no-account compadres. +They've done enough damage. +But isn't he your only hope? +Technically, a bee shouldn't be able to fly at all. +Their wings are too small... Haven't we heard this a million times? +"The surface area of the wings and body mass make no sense." +Get this on the air! +Got it. +Stand by. +We're going live. +The way we work may be a mystery to you. Making honey takes a lot of bees doing a lot of small jobs. +But let me tell you about a small job. If you do it well, it makes a big difference. +More than we realized. To us, to everyone. +That's why I want to get bees back to working together. That's the bee way! We're not made of Jell-O. +We get behind a fellow. +Black and yellow! +Hello! +Left, right, down, hover. +Hover? +Forget hover. +This isn't so hard. +Beep-beep! Beep-beep! +Barry, what happened?! +Wait, I think we were on autopilot the whole time. +That may have been helping me. +And now we're not! +So it turns out I cannot fly a plane. +All of you, let's get behind this fellow! Move it out! +Move out! +Our only chance is if I do what I'd do, you copy me with the wings of the plane! +Don't have to yell. +I'm not yelling! We're in a lot of trouble. +It's very hard to concentrate with that panicky tone in your voice! +It's not a tone. I'm panicking! +I can't do this! +Vanessa, pull yourself together. You have to snap out of it! +You snap out of it. +You snap out of it. +You snap out of it! +You snap out of it! +You snap out of it! +You snap out of it! +You snap out of it! +You snap out of it! +Hold it! +Why? Come on, it's my turn. +How is the plane flying? +I don't know. +Hello? +Benson, got any flowers for a happy occasion in there? +The Pollen Jocks! +They do get behind a fellow. +Black and yellow. +Hello. +All right, let's drop this tin can on the blacktop. +Where? I can't see anything. Can you? +No, nothing. It's all cloudy. +Come on. You got to think bee, Barry. +Thinking bee. +Thinking bee. +Thinking bee! +Thinking bee! Thinking bee! +Wait a minute. I think I'm feeling something. +What? +I don't know. It's strong, pulling me. +Like a 27-million-year-old instinct. +Bring the nose down. +Thinking bee! +Thinking bee! Thinking bee! +What in the world is on the tarmac? +Get some lights on that! +Thinking bee! +Thinking bee! Thinking bee! +Vanessa, aim for the flower. +OK. +Cut the engines. We're going in on bee power. Ready, boys? +Affirmative! +Good. Good. Easy, now. That's it. +Land on that flower! +Ready? Full reverse! +Spin it around! +Not that flower! The other one! +Which one? +That flower. +I'm aiming at the flower! +That's a fat guy in a flowered shirt. +I mean the giant pulsating flower made of millions of bees! +Pull forward. Nose down. Tail up. +Rotate around it. +This is insane, Barry! +This's the only way I know how to fly. +Am I koo-koo-kachoo, or is this plane flying in an insect-like pattern? +Get your nose in there. Don't be afraid. Smell it. Full reverse! +Just drop it. Be a part of it. +Aim for the center! +Now drop it in! Drop it in, woman! +Come on, already. +Barry, we did it! You taught me how to fly! +Yes. No high-five! +Right. +Barry, it worked! +Did you see the giant flower? +What giant flower? Where? Of course +I saw the flower! That was genius! +Thank you. +But we're not done yet. +Listen, everyone! +This runway is covered with the last pollen from the last flowers available anywhere on Earth. +That means this is our Last Chance. We're the only ones who make honey, pollinate flowers and dress like this. +If we're gonna survive as a species, this is our moment! What do you say? +Are we going to be bees, or just Museum of Natural History keychains? +We're bees! +Keychain! +Then follow me! Except Keychain. +Hold on, Barry. Here. You've earned this. +Yeah! +I'm a Pollen Jock! And it's a perfect fit. All I gotta do are the sleeves. +Oh, yeah. +That's our Barry. +Mom! The bees are back! +If anybody needs to make a call, now's the time. I got a feeling we'll be working late tonight! +Here's your change. Have a great afternoon! Can I help who's next? +Would you like some honey with that? +It is bee-approved. Don't forget these. +Milk, cream, cheese, it's all me. And I don't see a nickel! +Sometimes I just feel like a piece of meat! +I had no idea. +Barry, I'm sorry. +Have you got a moment? +Would you excuse me? +My mosquito associate will help you. +Sorry I'm late. +He's a lawyer too? +I was already a blood-sucking parasite. All I needed was a briefcase. +Have a great afternoon! +Barry, I just got this huge tulip order, and I can't get them anywhere. +No problem, Vannie. Just leave it to me. +You're a lifesaver, Barry. Can I help who's next? +All right, scramble, jocks! It's time to fly. +Thank you, Barry! +That bee is living my life! +Let it go, Kenny. +When will this nightmare end?! +Let it all go. +Beautiful day to fly. +Sure is. +Between you and me, +I was dying to get out of that office. +You have got to start thinking bee, my friend. +Thinking bee! +Me? +Hold it. Let's just stop for a second. Hold it. +I'm sorry. I'm sorry, everyone. Can we stop here? +I'm not making a major life decision during a production number! +All right. Take ten, everybody. Wrap it up, guys. +I had virtually no rehearsal for that. \ No newline at end of file diff --git a/src/lib/tests/test_data/langsmith_js_wrap_openai_default.json b/src/lib/tests/test_data/langsmith_js_wrap_openai_default.json new file mode 100644 index 0000000..722d749 --- /dev/null +++ b/src/lib/tests/test_data/langsmith_js_wrap_openai_default.json @@ -0,0 +1,98 @@ +{ + "post": [ + { + "session_name": "default", + "id": "dc34609e-3eeb-459d-bc2a-6fedb01d2e6e", + "name": "ChatOpenAI", + "start_time": 1728803137170, + "run_type": "llm", + "extra": { + "metadata": { + "ls_provider": "openai", + "ls_model_type": "chat", + "ls_model_name": "gpt-4o-mini" + }, + "runtime": { + "library": "langsmith", + "runtime": "node", + "sdk": "langsmith-js", + "sdk_version": "0.1.65" + } + }, + "serialized": {}, + "inputs": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "howdy" + } + ] + }, + "child_runs": [], + "trace_id": "dc34609e-3eeb-459d-bc2a-6fedb01d2e6e", + "dotted_order": "20241013T070537170001Zdc34609e-3eeb-459d-bc2a-6fedb01d2e6e", + "tags": [] + } + ], + "patch": [ + { + "end_time": 1728803138285, + "inputs": { + "model": "gpt-4o-mini", + "messages": [ + { + "role": "user", + "content": "howdy" + } + ] + }, + "outputs": { + "id": "chatcmpl-AHmxWgRAkoZJCaH30D7gz5t1OCc30", + "object": "chat.completion", + "created": 1728803138, + "model": "gpt-4o-mini-2024-07-18", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Howdy! How can I assist you today?", + "refusal": null + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "system_fingerprint": "fp_e2bde53e6e", + "usage_metadata": { + "input_tokens": 9, + "output_tokens": 9, + "total_tokens": 18, + "input_token_details": { + "cache_read": 0 + }, + "output_token_details": { + "reasoning": 0 + } + } + }, + "extra": { + "metadata": { + "ls_provider": "openai", + "ls_model_type": "chat", + "ls_model_name": "gpt-4o-mini" + }, + "runtime": { + "library": "langsmith", + "runtime": "node", + "sdk": "langsmith-js", + "sdk_version": "0.1.65" + } + }, + "dotted_order": "20241013T070537170001Zdc34609e-3eeb-459d-bc2a-6fedb01d2e6e", + "trace_id": "dc34609e-3eeb-459d-bc2a-6fedb01d2e6e", + "tags": [] + } + ] +} \ No newline at end of file diff --git a/src/lib/tests/test_data/langsmith_js_wrap_openai_reasoning.json b/src/lib/tests/test_data/langsmith_js_wrap_openai_reasoning.json new file mode 100644 index 0000000..6435c46 --- /dev/null +++ b/src/lib/tests/test_data/langsmith_js_wrap_openai_reasoning.json @@ -0,0 +1,97 @@ +{ + "post": [ + { + "session_name": "default", + "id": "e954b8e3-c337-4a05-bf0a-ca2baac3ba48", + "name": "ChatOpenAI", + "start_time": 1728803138291, + "run_type": "llm", + "extra": { + "metadata": { + "ls_provider": "openai", + "ls_model_type": "chat", + "ls_model_name": "o1-mini" + }, + "runtime": { + "library": "langsmith", + "runtime": "node", + "sdk": "langsmith-js", + "sdk_version": "0.1.65" + } + }, + "serialized": {}, + "inputs": { + "model": "o1-mini", + "messages": [ + { + "role": "user", + "content": "Write a bash script that takes a matrix represented as a string with format '[1,2],[3,4],[5,6]' and prints the transpose in the same format." + } + ] + }, + "child_runs": [], + "trace_id": "e954b8e3-c337-4a05-bf0a-ca2baac3ba48", + "dotted_order": "20241013T070538291001Ze954b8e3-c337-4a05-bf0a-ca2baac3ba48", + "tags": [] + } + ], + "patch": [ + { + "end_time": 1728803148730, + "inputs": { + "model": "o1-mini", + "messages": [ + { + "role": "user", + "content": "Write a bash script that takes a matrix represented as a string with format '[1,2],[3,4],[5,6]' and prints the transpose in the same format." + } + ] + }, + "outputs": { + "id": "chatcmpl-AHmxWXSp7oUeT2kkt7kuhLOmfLKDJ", + "object": "chat.completion", + "created": 1728803138, + "model": "o1-mini-2024-09-12", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Certainly! Below is a Bash script that takes a matrix represented as a string in the format `\"[1,2],[3,4],[5,6]\"` and outputs its transpose in the same format.\n\n### Script: `transpose_matrix.sh`\n\n```bash\n#!/bin/bash\n\n# Check if an argument is provided\nif [ $# -ne 1 ]; then\n echo \"Usage: $0 \\\"[1,2],[3,4],[5,6]\\\"\"\n exit 1\nfi\n\ninput=\"$1\"\n\n# Function to trim leading and trailing brackets\ntrim_brackets() {\n local str=\"$1\"\n str=\"${str#[}\"\n str=\"${str%]}\"\n echo \"$str\"\n}\n\n# Remove outer brackets if present and split the matrix into rows\ntrimmed_input=$(trim_brackets \"$input\")\nIFS=\"],[\" read -r -a rows <<< \"$trimmed_input\"\n\n# Initialize an array of arrays to hold the matrix\ndeclare -a matrix\nnum_cols=0\n\n# Parse each row into the matrix array\nfor row in \"${rows[@]}\"; do\n IFS=',' read -r -a cols <<< \"$row\"\n matrix+=(\"${cols[@]}\")\n if [ \"${#cols[@]}\" -gt \"$num_cols\" ]; then\n num_cols=\"${#cols[@]}\"\n fi\ndone\n\n# Determine the number of rows\nnum_rows=\"${#rows[@]}\"\n\n# Initialize an array to hold the transposed matrix\ndeclare -a transpose\n\n# Build the transpose by iterating over columns and rows\nfor ((c=0; c07FCGli@-TZYdHe|-zfg)0+UfC zzXAY&02y%+HIMAGE_b!>r>&2#08w}jVXu;~4e!ezmwIQ^@ZmrHZFG>914+BcNl5NC zyrq;Oiyx66^d_EnV^hk#KPLnpyAK5pH`2OVN3RL3tLS0HC^siFodr#yfbr6r4v?rq zl}NpbpC~{2(1d#dy_5lD0Re0P8*pxL*fz)@1%MO)>=950w*?E_qQv0@BLIWshlk7p z!~(#HphN%n>q&_Jo}&8m1EcqA5w^bneqMt5A8i)?kN$J`kIv`+NB5`xqwoKBc_fzq z?grf-sOG^~E0n z;aUU!&n6bY5mHs2fFxG<2eSpVf~(BLN@5{ZLC7T)l&5Bu9zH_%*A(jdAt0?n zeT3C=zFD903BRUM)23C+wtlzrb*HLPw}wqivnfxrB~z~<&#Kj$vWcpasr-=2e(#Fs zyPr4zgxeP>gOZwq+9TXu!>%lyOaB^UFC3pT6UzfxWztKQev79G930d&SWpjm&@a0LnX{x=3h0=%K8;-aw$6%n>>4HYzkYjF(o-}jTl0H9 zdcF@HTtvArz+UfWZ1c-?f9Ae?Fu)jY>Lfq7XS++W zi&TrmL=O$}hWM*g)WZ%ymo5%MT*;vE9}NbzWIcQ3DNcfQ1!DXpTZ|qg&QOp{A8)9_ zmOB^hc6ypSxT{z`mTSFbN@DDxZ+m-~K5u2Z=<$82i6iRq3A(#48cd73$osaAZLVuB zNifh}4YM-dffubadOynARMfAAazSZjJz7AK_Te5D8UQAokIFfco>rNxy}h+ZuG>A);iu>D-hOm*-!)oGtn2r>_i#$px+(bk<70ULQHim~ z*9Ge&EpEa20^+2@JG0Mi3EB0l})+1+Z^8|JCcurG9`ZWUW682-O7OX=a2MPaPbyt<_2?ZaP zCcBz*j+=7M8*=>3dzejnxDT!zXRdFn8sozZAo<_Ghwsx+?1G9;7f~kiun9INmnNf^ z5%0A-Jx$k?t2I>1|H-Ndd1hMK(1xW3y@Kr}zHXO60IYnbhvvmY<;nIHz0L9(-FiA6 z^2;9b!ye+x823JWUv|S{D1)NQ`p2Z64#q8izL}4eVIRj$sv6tTV*j&rPs59A(NuKN zp1fb}BCh{r-hc93IUcRLO@m5fimEaAwjTP@x%FYWasOVm`>sEF?thEJ*Y{aTyRx|lvT4O){XzZe#zl`lG}jQ`u~J%utD$!wP} zmF?2z5at|P>ReEs+b`Scg?snSab6V*dxSH$>V?$7yvv%yeQr+lC@&suC-0*IcFkk4zmbufw|ENi1QY?7MIT{OO;mIDKipsVytJUT6yStQEL~#v-a{Yu@5>d9Q zZZfA~%DQGG__5;DKIm1m>1McO=lJ*IJ)NxB(L6Bw+kj1(6#d}Ov6q+VRX(Ij{3a*4 zd94iExFX!wcbUwW25A|KOq*dc2!!i67-8^;WU-=!@0hf<$(x?#k2TXro4@8;bvo?x z^z&LbdeMTxh`YK0T^Gv1JA-(90>vyAXaInS$ctLOKV=^FmUp^*F9XDn>Kx3&Kqv3^ zi!9yu3)S5Z-q{3DR0J1jR)Djyi}M!34I%g;Dj4|^=3EeQxr?|V7qvI&>-dDz z#-~I_cXSV{Li{%QQk<2O7&tX=n##{whbjaIV6~7j=OtMYNjtREY*TGDg=q8+^O_{8 zzIb2J+u+R^Y%(yItwvdlqMo-hG3_^!6k)X*7_TDuvj3c}Tm7swER_;u>-eF^G zsvlf(V(H&&!2363lM0|`uiZwE?XrIRtv>s`ejyaB;}OD*7~1v#hMwwGUeYUOvy08* ze6GUA%uM|kg$bRIAstj$yvD^}T6BtS@&HXM@3-5pP^Ekm32*ko8wTQrs&VaUSzvMN z@|^yb(~>i`=$(GPp*>#4kws*I6=tQ&7ohQvsb7}NkrmxT3+}t`_>$O=uZy=slOm|rg)jHOJ;}r(EFm!zMlwO}GTxsVK+TS)1U$mWZL86( zcRNkB)#rP-Sa)^#oS^H_aCpd^d#T{cRjKW)P`MQ^+DbV2t;cQ3FPc$UULkLUx*v{1 z4i?-i%k(`SQz8<8G}2%VwpRllT)-L}=v0)Ty&D0FI##UwEx7tvgiKtf2UPg&lkTFG z?cy!8|KPadXt?HOvsBzOx#gOs=o==f(nwpYO2MnN4;JP}li9!%kY7a=EZ&b{GDPt> zEc6W^!LPVEG-dqnMq#zZX8E`6?L+md`z0^VmOJ~^QBDBIGPY~wWo2?$#iBUtXrA?& zV2}n`I9-y%K>M#3Ja#PfpTz-`bou3)GEM2}Xc)MJg}n2IET1b*HLG5Zy$8GL{biPA zObCnyn;q{rednVvt&>r9#E0vflU_dQ_fPDX?abC(f|e^nHdE81RsKc#GruzRD+x5J z-netz*g{Vp19qAS!}^N0892gA9)^XG0C42&*K)=2(G2qIAdjl$I=9`0@ydnj`iA=Y zx`wsii>GV%bX<1XCIT)sQRdw{r!EKhcN!VVj!msKf2Zma@mzjY<4p|qwvkZJg0k%=dfHEa)l? z0Em#>4)=gyBQsZ8#In|?`C|OM^!|d5KU!Yl4hf0#a>?s@;C|Je$$?D6We+!&Ju9}m zB^$pk_oO*8PT9RBk#a<~LPgpOZ{$UugEF`#B+cNMJa2gVxpV98RjN-F)Gg5V?>d0HE296y_5Vt(Kj}LaG^3cxc=l&@A9nab0${hVzqXc-~GwGHK}8l3!f*~ujbM{elG7Xefy{0hfD~u2HG~t`!_{M&I z<+x_&xa4Iv<aqVKl?#Bm}l78ow z-e30|5A$wr%T{K~Prs3m-KzIRiH>KMtWw&d^usZ_zPrl4yK;w^0ldaI1jf$~d!o;W z%EO-b23?2Cux}CwYnN~PU89E*hf0l#d1eGc3d{cKb+623pMHPc411kQH*JiDd@Pop z&I1(t`R4yD?#-F$>LLjiCf_l~IfY6IWC-)2%dEO2RM}95r3RLTCCJ(3_6Pu|X=Es{ zO!1|39@TTdnAh&`Tz|36;Ntc8$5CcY8!+C6zV0q?jLjLL9) zeU=GBVM8W1KNA{X{xa&PRhYxL+gP?F_0rwM^UY37VS068t0O&EQf433kk@Pg$JZtpo z-Dca7R=I39nq5~f&R5+om)gBw9|ZUw2?af#+IsBs?T5~h?nBMQ^eB`1-JExkFUtsp zCcX*-*EjaVyV1f~`#jx`^UBlz5#X}N`(w41sK*zpsWVT&FL>?p;Xe3u@sTkIqm}7$ zU685GH{DSy+bM(CDgC@Y^PD};*Ov2rz@&55qHQq0Rq0W^)=VKycEeg$Hfb%&YI#ak z0yaSY2AA_$li+#E`Q+!Qi7mFL3?e^6fUXnFryf|mFzM8cs#PW)r=r_MCBH_MUbEZi zi$X_>&1#49V&M8K3h~{;J&(`WqlI$7=W)`9$VZ%ARmBc&XC)WTaYF`PT?W3ZCgzVT zzk%!is@*crvPpyq13_WNPb|E1$Iq`?x;C%Cbn+`=`e-?P@E?*>d+Q3y^Yr97WD>ij zj96p+58TpBj(=mrQRA!0QMA#eE3nt5%OWVkTV01o;Vf5C9E^u+%v9)yOyOp_Mdc4 zE8JVy-rb!09}vF$03-IYEL%|cd*?)+nV#VIZ=X!pXYTVm!UslxI}v{cIEtKvJvL)U zW*BcCiF;-gf;`D9g>q++MMvJuq8`a<8%^Q<78=P{Bk*Jo9RLYV0yi&o@@068xu}5A z#ai7C*LSnp>JDU&b0MeR*CoR|;Z3qYziz5nLOzT=ciyAA&yTW2g36tHUg!IbRGi28 zOe9XN=*|WLe`A4VTfTK`KDHlRd?rFdJ9W%fJzwBHspCFj7P8^v0$BPDx%my*wQRaI z%sN&5b7`8esM@fons%sK{?Ro5<0+-D7r)DWy}fqRa?y>>v{jCZBSK;>ARgYK7H4e; zyCa*-k5TV}UE`WNYAZ`xkU(seSZ4J({lp_AL?akGk~D2?&|e5pY-;L)=yIk|^+yQEO?~w?+|?bX^wRD~eAMjRY*2#l@(sr| z1@94rkHVgg#Gw=0q!rWMLUh@bd)AP7R-b!TpLtTBdC`)2(vaz4%(7u8cs*iM2(g02 zykW<(V#2R#&Ajk~|6|0Yu-~L`_RH?B^vXa|*o_4`)lE^E8;ls6)GtkV{L~Pw@O{lb z2~o@F<};>B4*PM9*WJV9c^)Ty7Av{1C#8I9?o3sm z--g~zpqx$cB7+T(EQ(kx#n3Y|_AM3blC*DcKHk$i!BVMkzFUyH4s*jisWRT=3 z;8nQBLyFKIw;2cJD(d{ymulHK)g_~*!o+t^%e+g*e+1z?s%PG*<34KOA*taZtKlK4 z9UBB`ICEaJt?;l|G4MziC_y5YpQWg()Z-#y^BbkEXzib>mJDOo`?o%GG(U6q5c zF+yt~^kTsR94Pg6AeCsDv34i_yikfE5=54q#TDE5i`PH=8Qmh^woM8zal74w0lp^`qt#=(`{h+S?w3m~=7$fyeC`BSH#ZxN|E#kbI~sqd z{LX2-O&zDp)vKfTAE=61D338+pt9gBwcu)O*74xAJ#IhG@FHApuf2uLAlb|>o7&P# z5V4Q&GpP6w{=M;|WWyol!=h{3qG($>YFjx=WYaq5qLnvCfVX-b4=ZP`N~64(@~RM- zzXA+}u5X>yp%F)y;Y(Y6p*9YjVrQ7dXn#}Se$YLYZ24lyNbS&s8Bu%ZeP1vjtJE5n z`O^UvFK2($g9QMYA=@d&UQE=`t*vQz*J!p{&6hTxw%RT=8PAsH=y0B|w7X8my6SMP zbZ~2GYPWf{U94O-Umg`~<-4Bg{G8t&NkKbNXPUDvoIeGkH{m5VXDw$k=#GQB7b+3Y z7c+Qsmc0a?U*7+DL6~}pVZHRvzmU29+_k0LtyKu)i+SBRa_?TS@+|^A{scW_#?LbL zTVi=G19Ins!qm6)dD_(BYp?_9p0|vMxbq-_*!pO?HBmKAxOKKQ;TMEL9n^XQ+}T|> zog0F=Vuz9g4}YS`*&g*^07^m1&<-x{Zdw;7lok*mJIyY$+e}xTh3eH7!}C;Ey2a`S zcgMPh`WxNtW*{`mD=R zxtq&b%hBo3#m(n_b&}i!TNnUHob#{PHDl6;2XuRuaY9 zuE~e1Q%h`p?xuxqFC98Vns|FQ9YZm-7R=jgoGn-adHF`Z|vq!eAhTg4qV%Uvu z9AuSP<5u8I_`)6xu$5pW^ILvUYM*PgP+XCH&426>vnQd=3&??9L1FNFLr``5q2s*? z;yc@#BOlnwDShHUD+pBaa`7b@e^-AJDbyuoRCF~K1!ol{g*DzfX_TXCH?A38@r zamKcMTlfaz1bx{i*V)RMtHq><|78)?TP#c-Y`X{L)>(bWVcPAbMT&q~vLJ{N>3fC8 zFKx7Iq=0rvv+t2+bV?lpa)%0mb{ZM__Q5I!S(cY{Nj04B51{b{4Q&PcCTo4`WY?+N zm8*(z^m_2eu!VrI`Jk6F@DV^O6aY4#T&gJ7ZKo5VC>8UOMcE$<3SI!Mw^PnXQc5}^6xF4C`HP%wYOFn~r#^xx zn(p!!GC51Byq-+0{TBar{if{I9Fxbwv$4H;W&4M@iED}7cCi~kP z?B7kuKPB0)vz$})ZVmO$^{wu%E)LF4Hx7;t&JA_V?3^r|t?ce!cRI)Q;3x3;6CP2E zEnfxT`Teqc@8+v=z~o2KMp&Fpr}GB)b)b`1y6Od6^B?RJ3Kl$5xUm}(GoCQ5zS(kF zbI_rMI+g_}&2nDNa$&(zozntyVY<;8&eAn~&dbE8OVcK2=oYX1Ch+226!5F}T^1HV z0KXg@-niT+pNH1mCrpE{T*S((et;v_#|3wpB)s;RU3bHtYe*j!AM_sfc8byF zVjWmqj58uzx^VF28Jx&1EsAcb4+{eal#->ArJ&Mp52!c~6UebOiW5YPc64h33-kuR z4ewXRPdRNi+Z+c=|EKv_YxX^ly~jt^IY%}l=uqtPF*v@!*q@T#H=iC!fcvE~{A=1K zg*(?ux7|sXlZ-QsASo`6QPoF<{<%=?O(IlWoC3BwWZ;!pP1Nu+z@h<&>b zm>t5(P8Nl~3lz4Kp9@qtu*L--)yD+oP7db21vSkUE#>3 z6nu@^l?Jo*1*Fp!ySughvxC|O&h*Y#`1w!dU7PZm5{Hs360!T11F?K)kF)@1e+k>&T%YQn3^`++?U4taA`5L^%&*FmoH| z4GCf!v(K`R-|vHSEntYJ-urQ=$Z-0Yjuid+hjnn@fCvadR zUZVwfmn&$tdmJS(7XzpvR2d+Mo(M}b6c4&?IWmxMk4iOf8J@Qaf?1)A6nxWK!q-KY z$AS_2{_jZG;+N(I7o0mV(WFC8C6|;2Oh_I3_nqYDDOf;&TSx`-ygdE*mptQD3~CuC zs;sQ&)WyYBn6c8z3(E5xGi>$j^+6*f;b0acW3%(7zq9D(@Y_0S@9uOP|9EC~?USg> z`4{S;_T3|T-c#I^;?LnTV!ZPuES|cQ+8#Cr!;iD-=71AL%(qd#v(0Ab{=Vn((tx3>2E^aZ3tD$1 zHgAFtCMht4MwPR@>p9B(L7Gu)Z}P*=+R(`GkFtuWoUGp~Iy?mhT~YYpbjmRvZ|2JY zbScQ}n;Qret6Xn#YGHkDVZLi(-m|h`7FSS#@^3870!z3_NXA7_PS-J^^8(s*)Hia` zc;&-kqyXDwlY6J0{27>fM#Ffb`*SGN#v-u4S#+qQb+mU~2n^V{k3fi0K@;QeSC z0tx0UyEPps*%O_c0eDNl)#2BN3ZV0~l^^DU>aQ{oWS+64zSV?9e#>HxWIx)TxiTX8tIGFL3mLGbHN}mtIH{>L zxfCEw`M%jB0v;G2;HFH9ii&8FTud&jD(es=SvbuqPLtWy*{2^AfigW7_XAhBiBH6Z zU(BgXTHi6I^8(p&lQ4yJ28(FkuQJLH5m%4t+iz8%ub|F{8s5W{bJZ-E#LvL0V){1= zmA%BQFJ=Mgzn)S!)SB*^uE}>kFT5#~rFk-wUudhgMLH{(`u^mZ_-5}pMD97o9(jf~ zy-<4Y`i?9pH)udw8enel>0etktT`gTsAVoa+gM~p)xo2i0m?tnk_Ck9Zk6!+8Yxky z!AVzz)%e1X-^8YC`$mzkcCf;Ip#JCpY5kaQt8m-VHzK`Og5atLH)~1P=}Gj*NM8P- zFXmB{XBZYUUp=twC3ajeK?1--|5DSU(wIaCz4`B+Q{!SPewVaH*w}-e)}4Y|$^r7` zQ2aI^t!`7)@{eoZXYo8_+quAoywp6KCYJ~3oRDk0>3(Y(de$akPrQW)AeQ({f=1nd zeCGRL`0)s_0Xu{Plkt5snFFeUQsjkO8Xsy6t(OQO(*?X>2tN;rl@TZ$_GP7c|C_Au zWfZ4+rn5h!L0fPity9vpg*ei33aLj0C8On04e4|o^QZwPd{qyyuq>~8zUd z--UnOvKJ6$*!u`Ki=Tp;k<9n@*`TM^3`p()+=QT~69tL%if!z0;lLkAARPR=JuJR8 zyW(M68lwq~meF0o3FVUAjhF$y16x(l+c-WBWC07>~D-mBUn+rb3CMRPJ3XYGhyjW#Uxa7uyr60h9mx z&!=Iz{LkN0IPBx_#!Y!YPUM(TfJ@mx-rO%Qp{#C~S$PWRWJun_65+-P#(3yM1uSo} z_U5o&mE(yw7bJCDXN!b3p$$hn)>p~IvoQdK(;>Sdl>|Cxp& z7==2MdO8EmKeC+6MJB2&9_WX1(MHonOO&Kb6{K$mu-b~aA7CRWdV%#C`x0R{Ijk=U z4h^$*1X_B~NM+#Uh`pcma6@!5*7gIL1btbryVb$4$Yl|B*s!CuXp5UgHoo#}Sc=?N z=uBt9O1A|G$`-W3@nC;W#ET3P&^xHB1Z__0Y+lO62e{c+lGq@e%t*w<#%s0|^Mp{q zlpBEh?bKQi`ZagKqRZa$%Y`<>;O##|*v*Md#(n!K&A-9opblrFE97Cyax z)>!_{sqlNJ|G_J!xT6hio>D4P)p*)gk7lv3WdGXN0JdI#KFCBI87wZSqF@ha{~%Z3 z#s_8}R3;W>b%)r-U3k+jjh{c}29?M*jvQVl{1A8=LcT=K<49i;?{Yb(?3<3!4WG|R zzRyaX{0lX&u@5f$4i8d;8IbM}_BH^Bg~=42!Z@NvzcVVdMJu>9B7{n}ku-vh6@~rx zHbh1^KxBg8ZlDTRI%5`SEE^(3lE4|Oh@Z9{OXDC3duFJrDn!wlf<8uO%dIq5H!wsE zj}x#sN%D!3a5YI2n2#l%C>@!R{lS=>A=G!ny>S6K{Y)@?%j$V1wgkq9IfVO22bnwYqmzzre30ZbK&lp#(V$rd+Nh%i$Kwh<8m z+C<$R3U6miUxT;%B&a>-s$S=6gAvlCOu^|7317T%0rL5oZ_S~FUy7w)$W`N!_S%Zz z*I4Mi(@~cdyV4bwJrat~i-9EoAzN%Xh^tL-K#ZDlf?16^yO3-h_s>>1a^4&gPgrO% zEm%FZW3A!Mv%U~2qd_8Q>Lq0=LG?LM^;)Ff!*Dqd zcPKabgjCw!1R^0?CSH(cBu}_BMotvY5R-UTqFrBl`8C|A9XZNWx@c>1TkH?+mOmHd zSEb0WJ*>a+H|xhmSSzoh128DAhdZvCa<_6K8acM4l7mNauPhx_Zyzs1|GJfPOafio`_DK z>rS5fAW!k@Ae&|I7NZ1Ey)>hL9G!n0gO)G?LILr}w;%EERL~7(`8Ggl*upnQOn+To zTT#vn!V5c|WxgPzMu<3#K*3~uzctdGPjkc%6$(@}-l7B(GOhP@ZfAqec@~SnCb_G} ze+4AswGMB6X-h>BH-iPH1%=p7efvJs_?}B0646xivMS>v*M!le9TxcS#V zhu&0F54b6;gR%u=#&SVJ8L6WgeS71wZ~)ZFER69ijNv>35Id7j1JIxosMi@ln~pvH z?hn|)hY&?9?O_8RAw6NJb~5t%`Wo)@8t(F%mdgb_ zsW}MIDj}3iLda`VsN>TBEMR5l6468vVF6nK0YBLE7z6f$oYqzC;F<1>M{vA9PJBL5 zY_xD|vQ(-UcdG{;0Ip5^s~*o9ksG|@fjz0!K0Rv3TGiU}bZj3SZ1qtYYyBo&vp?=D>R>F^Nbvl{X{_aWoU;>n zM254~#xm8$a?#Boma}myvT+l#iJN13*FJZiKGlS9o&p2w2r^MI;(;kMwH^$4?H>f{ z@G=>Sv%;dMa4VfA8I2e*R7;-rnG6j&-IWmHFTN8-;P*Ghz-fNhex*aTz29M(F z2+(8EOy%LJkd$%19~BFRW=Y&39afB#;xrLt!^ATKK2K4lg}?uy{?(k_=T~)%mMsC; zIDnkiVy5VP`Q|w*c2iRp0-+M*I!!P8}PjPq!&TzWu}iVNc;`p}+&B{3i_R1>gaQ#q}1eA@Vmr9t27p={Dk34>Fi4 zybOEd{AwuMeV@(~h7CBJqB*P`whWzpJl9&8jlbyHE4aTnGoc%+!kG;;_h=Hr0kxit zM)&vpmtt+UUn*QJ>P#(qLf0{dvaN=)af+g?+CFhkvUzy+%4E*sbk1sg#%f>Io6908 zLrhhsL~SNNWG*jjDo@H*O3qp;{@J_ry8a%=_a2uf6#K`8Y#Wn z>|22*)F!#SGVKk!!5*U031*ZVqx!RU=}9oUnMbvqOQWBBrHF2aifS2L&`SWh#nYGDwjHjmFLP)iLsZBiO#G*T z(N)|j7-XVG;(}(Sg-`mAlc-i$;8HG-8wz8M2@SMKYUd@r_MGgnMX^97LJ~mcn#knE zi{wZf>Om>7ZES3mhw@$N|dx zwnqVE6KqwUAjv?U<^Zt)Wt||cEn*5M)ov7G z=OPIUjkNuTu98eyF}vF1iM;gaeDX!B_k}CXMjJ(;tGV6C9zv={Woi9mK)_ZE9Gh{)R97w2XQ1%#-ztz*d;|p$1uXhFv{2+1u_p5 zjHe!B6dvyy9mgO4dp1UYV~J>D3F&=--N4D@irkJJx|Q75432b{rLk6uXEvd^F6sUU z{GfR;Hv=?*XIe$Ea0hS+Mzh^hXo>_Eh#xw8Owx&4NS>ih(}5onzWYWoE3t=Dxbmm^ z(W;z-EA6_~m|0}#do*uHdF>vVH9m1h{dH?x;w-|pHdbpvLp! ziQI`Jfhv^}#*-+GDd@3OW3^R_XPyHcE-v6QUU4m0xbYL4~04lSrobt5$(@OoOWb7xxb} zcCZ|}k^M`Y4Z0~WRlBFo0rG~vGt($f-AYgSC>fJReUqq(QO@&pxAP}1-Uxfw#E#}9 zulz#l=N!CBjg0xxANMSpzL<(a8EnF`guRY1^=l-a8>m0I=m+srG0f zS&^b#Z^v?DXvRmg__68_b>|9u;RO4yKK5VJnFnkv@(~;T9-#v?(86@&{?USI}GmkziP11J|}=&zL0&S;S!|LE2%wC zu^Y?&^d`gOCPS^j(S6VU8_Rg+Kh7KRr1sKiUZeR&CqN=u`l(40$1qqX1C{rF>R*zq zSNW*m&|5-Cf!yGMDqo?Y#KHk!ekRs*N2LRkaITn`J&7(*o4Bx6VuMU_=_4tK6LqVg zinuidT^jjH0gU4>gX~u08#L12w%!<-_R_5SEMD8wO40I&k@1MxPO=I%Z+@A?cgf>A z7Oluvr)tsTsu5$lXjXi)uKufNHluUDXE&d0M*-r>nHN*?lrp_f)a)X6W6D^U+q*;K zd55$UiscK~N8W0K;3jfW+e0?;4E{r>No|a{(`^Zu#4=(FP&%h7e!%$tDw8b;-x2rc zqGX_H+RXsS+60Aguq6r$_xNRl5I|EJWn!B)Vso$bAKOYz$fT>eK)zfOZ~c<0Zn5RN z@b+68&ri7qB~p@rYSeVv{j?Bnf{0u18ZTOrT~duxtDg_&1YIzpl7=Oe1^bdDST7_g zFNW$~+h4++-jm_qlV-UTX}cD!-WjdB8m~6*^0Mz5uqE-%C-SN%ZTu>Bp($};Qw1Vg zoVlAk@R1%8hTg^|u1LwBlR3R!ulez#@S?Z9LvF*lgmfb7VbfxvlQhT0ugL8^ptXJ3 zF>%VeoM}%{o!1e`Kla)m;4w78i9flffZLQ$24VrivO80l_|YHwo!2DUzlr-;<1MhH zTlgg0y2Q0SlUlAxOkXiZaVt39m9X;wA_vQHtjH< z1b1?{G)8Bc$;IYscZjn#Jl~U3>CR{HYL_t4&)QX%0*y8gJz06*Z_Q3-<(j*A+HEoC zw_J$3J`!y^8*cqQ&c7sesUUHxAbGAJ`%atZIYxFw$7@6fuQF=jqR_FOCfiQdiYo;o zB5_4w&m~E<11{Sxaf{+^-rh^<8vvrO|Kw+EP@0+>ikgv#`hw0tzwQg3JwG&zfG0LI z01V=a3{qOAxbSDw0cYnnNl*pxG=G2vSo0t9vRk!B>$`b(OMFoaK2nR2-24T&Vsf3+)VZiE6kZs?| zULG7)a;ZlOJQ@t(r}HQ-F_^ZoC|gbiAEv&i1X#q(%4A}HZ?3s@wKqLkpIGQfsCFjP zxdr7ebb;r;KbC)}vByi#fodvLvIdz*;OZGr*%^bA{d|J8u zODgsU{LCxwL<1Ja&f@frVY!h%GJxlv(Ow~EQu#JE$)CWAcGc;jtRDw*?6*`IFX#*q z;mCbzt#o%O?l`AJEG+`YXOYE(;bg=zl+Ptwag?&>2r#Rcz=t(5V&57(%rZwtncIed zz$~qHE>_nXQ)YV;w#z+>&Cyvu<#IyRLM-hOAJqXL)tc)nRe{3H=R(k~X^yr_m#ln| zl~1FSXq2iEfYA*AT~TS1%5p8FRhjb&&6x(KB8(Q0wk+qbC6yDebWj@kHe5$A zDQe8Rh^f1>=DA5AHT@B(ODNJiDs4wF(Pp^|YjU9}>DTKb#8p9Ghl(38bonWIdq^ng z^2kZ@KH*d5uun|m;+1K+1)~^Uetk31n1U1Ij253bo>YNE(hcC<9y57?zc_Em=*%GGl(>1f{dnu3>tCe z%28FSzADuZ4IqwEeTweD+$Cuxt5Qm??^@&JAcTx{(Ss&s?rIZ7I-cf~_?4fZjETa3 zF9+>4xNF0+Iayz^Qf4I&ni8~Z$wBxuQ0Srak~>XkTDF*ObV#cB@20r#L+a?W(%-D0 zxBLLlv*rK`kr4VwtKjKPC1!46W`1F2UU}w6veoQjZoOj9wc^_LK~3U6OE)1cuTba0 zh~rcVXKC_R5lQ!6^zGZMMu!YRlNge{a`WFE@eUK48Ou&|_bm-%<~#q)w@oZ|=2Uo6 z$(`!gECln9hlP#}N{mraoNSv~C16>pr2JBumu2CQy>`6|@zOTJ;P{>K$BMK*Q~HdO zwJdbVvaht|$M0%QyDZ^q4}H4W)yTsZbjUwW1!+OIoTRNW)WT5c9Wg_8WOXaBHsKWT zFh&8rmIZZL6=!BvvJe6d@h!VJL`o^zH+NA_uSdi>;BYTLFsG$Kg9v4<*k!FKOcv16 zpg_#mAMLLS10#I0))y=b0>!p)C7u$>ox3#M!zP>Lp%9Z4oVqahOtRkVl&}1zkm59X z(dv9@e>?v!bPmn54XO5I{&FN!ICpD5>7u`f;lYc$nG?I2&%^x1PY3eT|Mga=du&>w zD$haI{WDopPUaU?346RIk~oMgc0GKToyH++&ttry(+pR?k-kTZv05T`lXTKrWviLG zxcSG7HghM)Q=jhVVx)}qzZ-4L-_>n%srEZfTgwdGZfv>h@;mGpJmXJuX8dIty>_Gx z_`_AuvN(C2J{>`%GUzWJAZ`?(za0Qo+9J5~z^v+mDEmS%JCLhyF3ki@xP?c;@we<_ zXGPNqvhldUz)zSa+k&}wmN_yR%uUQc1s1~!?tQT6DVeZPI; zHV2(k7V{dVyj2o-s|raoTECsz4>4{uReUVZn43;X&m*s3@G|`b_23RHIS0+qgycZZ zK?ct^G=I-`XE0$BioXd?YBDMN@a;k5KRf{DPRS;Y(KcA(ZroDtzvT`5f?E!VjNiou z4p7?{oip9R$C*NRFly__#9ij0>UJ=VMkU_5`R2$Bn*y-!Fs5axic%B@rzWYs+HO$$ zgyGr&?Ye?7{~n#d$PfOB!q9!?yaN?7cuN(EyCo+tT_}!%$aJ+(xeCzU+(_BNaOuLZ z6MlT>i>c*(d6O4rkAC(hFn1$d-d^04J;IEWVbn(4gq@T%i#%k+B3a%(SDyP%krSF`&HYP`H63A@-Jp3(wE>5J8SD)sHUIS18>unYM4Vd~8b& z zL148K@e&v+#~!r;#GgQ+=W+#A{WQ(EN5{S2!M!K_!kbrbSS+dKy^-a;g?=z%t$w5i zQx1qJ1r#S;DRp(CvWAD*fsU0%b?i-4R7C%OzBJ;UT&HnCQfuS;t4vwV!(w`D2TSy5$4W zY!GW_1OKW2@wz(>4opdgDmQ6DQibVnVhv@y9aYk0ar~uYc-)KwiI0yaG$TI(c1Bbb zWA6agC{-#AdD>_Lt#XArdZ(32ckeZBGqCZS$%61m4VYFlumgE-?z3R+r%*DPxM==Z zyS;2J%<(UfNi`K4Xde( zI~o#l;@u-UBJz%cPsG9C5QwVW&D>%F`#5(IhwhR&KVloEsk&fJ4{fiDDcn2H*ewLO zpa)||6?o_vp*-O|SCGZ04&mZkTo9)2}@6Yqin=H_Q3fDW1;*(kZf<;3x znu<7q@sm`JlUIYXMVPi;ll8X?Zijq`2H2B2un3RU7sN62p|L*~)Y&ei22}1A{zLb> zc{BMt3{8h(fH+}sG;h2L9XOi$m!^d!o29i&q^*9#c8KpCu*v9f;nF;1hz{l903hLM z#1dwf!WV_a=T?=bHlx_?NYaVOG@%o1Al2hfP1_H@!m>^ihr0zn@k~poN*C#nxBix! zK+=j6F})B>_FHcxV6Wvz=z3cHYb_m7chS}-syC_pwS3##Gj%8(%MI7gz8%%bhEl5U z-$b}wX~BJ2fkU#w2Gn}bpHVEG|8Ewc{4e-)GHEX(RHsvKEdW;?SP5^U5l9f|R4J^; zF;orhIS=fS+$}Q*GEq&i6 zYv7pEa>D8Q?)#FeyfWc%k}F-f=sfnI{P*RQv__=&nOYMge8pAY@zNCP@-)53eLpVj zhhpe3WZ0;#4~egU_5&z;tV?`ZYFO2<%2a8|XtK>H0oRCyr!c~klm;r`JEDgvu#3P1 zB-}k&iCGT6hoBAz|4UefgZkHV>5jJalnfGe&Cgf~4piuPD=75rPhnA5F@1)i>cISw z8bHQAunY1E{{H~VKsCR?OBV1th$Pb_QSi0_KwN`KA&f9zten1*(B4Dy)Kjer`F)E&&;ZfS(6KBf+L^5?HtyK>n-%Akau0 zQ$}+OR@V!XuoX72~&QYB%&|EH1-7iyC-J))MNZVBDgk+AAio?Glnbn14hqUZVHqec2PFcPR)De!P+kct zDg>$o6;+_}hpIu9Ko!-Xq6U=L{0kMO0$NI{pr2M)2J%WkP65cw1)Own_mPOT9TJ6x zM@5l8B>)mg8KNY^RbFe0qRvjN?jEfEeuB|ql34)7`V`eRnCftj?sS&wa-OpM8g1QO z`qohTt_b>`DC)i!gk4W$H{BCiegW9~1Izuu*&A#P65kt)I&cwr_}a2#x5a|)Ej#^4 z>|ChWm2lBJQQ&bLc#;5~CW2>)-~}7RrGexOz|I1^T#%jzvI-$z=9hxPQczR^iiF1! zP*Ms?OF?-Vs3?V0Sq7@hKutNQt^hR^(6PEwpej%+(6=j9psEs7R)EUN?}Su>@(NH^ z2K}^>Vo+EBa`HfSHsGa!)4`%lZ4?@Qs21|)13*FodP)k(QdWJVg7$Vr!O94N;Q^w_ z5olS2-5ID5vOi66I!pGrOkH)0wlRdUBZ9s=ioQ3B2b}>nKEP@p@bm%O zPb}MaPW<3S(W6&Ez)g`8cfsig;6f<45e^E<`>IGk_ zD+jgZ5GOTd0wgLxRXGGtRVk<}1?5Gcq!1M3f!s`xl@4A!2VP#HBr*c-h7ms-0MN&1 z%b*mrrB&Bqw6-hi?8NEqAsFn3mdBa;lB|QsHm9Jik`8AmuIDMsuTs}PWNZ&t+8w2| zH=4RT62I$-{FZymR$l?mLBP@*SnL7I4}+a2#P*+EcIbkL?-dYu1Dw1Kf**j(q2Nvg z2#p5evEW$@coGYul0hODu+l(kI^bu3j4Y6y19EdgejX^y2Zec{C=V3pfs#B>nh(kf zKt(>NDg-sfptc0mm4b#c&{zhV%AuoBO#=P-q_GV8HX$Lkr4TtaCD2c(E&>$=pezp* zXM_9Pc(h^3*xSD#T}1P8~!i$-ovZSE6@Lb zgKbcusP}>pLLh-elZ5EK7Xj*YFdf_2*ap)~F&HqtxtAnP9LJ6;w!ygIUSd0SCX>m` ze!t&6zxHSR&LbzYv%9l9liAtLObq9Jp7Vf_442RAe%igy9dpa>L(&cyYGABFK{G1a z-RrvCntHK)0J{#LV-(#J7?{D>5@wEJ_84Z5#A#j`cMSi{RZ`0OrT-6Qn+9$w%5ce+F9)m`G- zUf#v?yLfg7&u;&qCo9Cet>3`iYgoC6D`(Ny0Zqu3jboQQ-!%YS&rGL?VtoV6j9>>V zq@1a(VQOo*VKuzSdR|PMFmAUnez(ANo{}(kNNk>ymL8K;o|IJ_6_y`iS1d6dGo+$n zWbH#jD|FR}twKQ)D)*qi3oX6az8~#F=o~@MI0h#%GKYyJ%pAtT2^@b9AAO7q7YSXw zfJ+x}psIt2;nVuU?!1J8~ zz>`EGdy&~HZ+SjF$j(qXnZyF=YPPnD6H&`EY!k)q62-L(H==wO8avU_jU9d1GvH!i3m6Z{f2WczFXamtDG!7uWFY3Z7iX zgNs=C0P{nLkKW|7v3Zj8jsf6FB6;&D;$+`Iiz^VSU~4Mbp=E4sB`2zhPqYPg31ZuL ziMxfVU81Z3anY32dBoqjAa&03E0#EwhuNib6zjNK!2mKk5Z4S{6%6Ibu0!QcGbn(zzDK3RSuTg z$x=Ir!f1FMSHGPfvy&gQgB!n-pVT4D=n>})i%TY@mT9qlR#>_yC_l`rSfV>-Jxj)r z*N?Q_KQd53Fi_Ek=1y$ehn-#6(}#Wi=o`lHI1Wu=W)2GrID8n#ju54o_fFuG_i*I{ zTsw>94{`G&+&+g@*Y)0ctbc+BpWxv|Jidgdm+<^DUSGr4H(ZA9vi0w75!bJ-J#Hq~fb5idW%!w+zF9L_v9F57cs!@#=) zfNNclH;*DsqAQEpK^9h!jj6J;h?Jm`6WK@{rD1618nO4L_kCz`|^(f}{!5HpN_454r|0qAV-X#D?B$79Y9qKL1p$A!*L1sp;b@8+I3-X7BC5J@TX@PT|U%texSYnpVQ*9IOrUS_CLGmt_ zAE<`D61nxL+>YjUY}UQ;OZ${KaJ(n zxOf~Fj$!2t?tXxKXYueHo_vg_=kfeJVd$?u!J7;C`ZB(~hVPg0yJh_9GG3m;(+>%Q z{{Axl&vlos;h(SJpO^8wYxw3OzC4ewKlxGT|3#mFga^lQc>--t1j{#VCeovy|L^iM z>fHjsgG}<`kOk4+fhF`HGdPG4wf&qOk(I3dFMMW!G+OcB~c6DIyKJ@ls zXb_{rm>R?UBo0qwX$p&zI5mg!OSp6l*N)@rahzMihYPrR3~Q%x_dTq?kH=>T`+o5e zF=4;{7_ZOa&3Sxv0l&VCZ!h7iPw?s^Jo^C8-^1r0;`@vEzpmo{eyi*FUsnh#fBgY| z{V~4%n7ID>oa^ek`Q}4>b`p0NaHbFTbax*g&kX=b@03Us65XFF%kfp3X-bRB3E1f& z4yLAz6;{QH+{)HBa$>ge5_a)ZJNTJ>{JbGy(WuBgA+XO1oC}-exo&cFn78tw(3K+@~W{{cbVt^T1%FB*#c=G|ibm=SC^^5oM`5D5{zc_<$&*Fc7jQ{6T{Qf*vXYu|J zZcXEh)A;_ZOYh^i@8P@mi0g08;IqS6o500>RAxfNA-V$Ztll{Q-ipel3iPyqLKg!T zW{{N`Y-ffznVJe_L@hh2o^5Dh#kFyg_i)p@c-i~;`3LyL<9y2$&oRp_UtpCl(#jTm z95Wu4Lof}y7dAH}J0LTAQtegfC0u`-7{3%Gw6kB;H_ zF?@C$pPwQu`_W-MIfAcFDsNdPP5EO& ztge1Yc-P_T*6Fs}pzZ-+QG2DTJ`*5fm(|uSN z#L;1#93`y(^cdbB#f5QPpTw;xtj%J59*-CBd=ak>4%?FoE!6ki;{n9CA1(jE!fHku`xrP3~f0pyqXi|q1wcIhm`I^kP1LdxxT%jhJ^qp_`s ztV388j1?%VL1P1Ux1w_gdUm337Y24?Xb*wG_&&_^VzD2`hjHp4P9Man5qvO=izB!` zhFgblXA)2sGXs4GQuiYku^+RJu|w66Ssqt)Xq)c$ItBLM)5&vUjOFIE+n@T3`EsKQ;o<mcro;r=)tP2%aa%ce(hb%eM+ zo58OR$MY;Se4lz=I*& zA0({*-hSNa#aa(O+J?FmH-@jrJ6IBZrvV_UqGXa6pDHl;1QxoSfSIATF+z#X03*DT z8AT)mEMqe_ZU--U4?k@mFQb>2H^40%=9$O%_DOE(oS z1Di9tkhB{y+Yng~Z8gFwkXQjr4YoC)qZ!?;=-Gz;9T?h)gS#-X2XkFG(u3oDIN6UA z{W#uB)agGR#PT3khKX$T{wN+D!rgJ)7{S#cTpq;o5D~|JHHYt)@cTvlegWSv;I|9- zb`HOq!WHH9u&iueXrJVijQZvcdS-UJC+~KP*@mbFXloE!f$%b9 zSE8mCyBpEngx(hPw_cr75oan`|ZXD^tdp)?+iyQs8Ie^tc+#M#S z^W|Y&8^W~#eA%&+Zz;YL^ zbl`d??sQ|l8|z)TzYq61aAz;>?7^j0G^aqo-14ril->Zq#Q?EPh$=MrC<<)69t{Vj*@~2PdRV6}85myRlHQMUX-H6^s^fh6q1*6+Bu@iIcSlELj z9XQ&F!<{(Xf%iIbsR!44akCGr{a7Br)dK_o*9UNQKR)fo`MtQb4-W_N;u@J}D%#PpAN!-Or-OJ7F<`N7H z@hxK_$FyJNlCtWEvhr|1#iFcqPGq0tmy9v;2dU}%JQ7`gK;MAyTBs`!T#ATNWLKb} z7M=CzZNUCU3^d_jE2ef}whfCrakw2v+OgD5+??5qPdadIAFg%bLN_k<5!QcW05|sI zdJisj;=*o1%lq)C4__R>Z^rQ3aeP0HZ%6U<2;L0gvVGC1#8IcNibKN=2Vr4WT5^@ARA*FO}1tX%GsoTmj zG_Yb?xbfRLNko5uo7u(7?c)_45L(7%rL)S)#o+40s_Mha%0+qEoYX!kC>dqt4)~^Z zk&;{t=o%1SO)#KzBE*iQGE`JyPc3@tu)iKdjTmdjWGm*jV_^rDb`aP39XPxTC)@F1 zJI?LF`+IS=6IXhOjP~Y!+~~n_C$8_ldOL1+;7Jd@K7ijI#IHy2!pxr3))PSn0;fKCJA;&33}3 zFK)+2EjZnXQ+2qs9k2WF+aY|@k2k&etOw8b;n7~)-;32fxUmygT5+`**PC&(8EehB z(}erYxKxeZ$q=!&Z1N<(BbmSk0DmeLqLVplFaLBOWr$mD0{h@8D+j@C%0|<}tZ_DyVEuRWTo2u^3#rpeUd7b4&@$2MGo;_K}iy zZ;EMk)724mp+7JXVnw%y=0#7F=({)p}g3$BhQ8*5gh+Zq?wV8JR)uKGb(*al{4ye|lUfzGQ~N%P-X@ z*zBva(bP7&#>v!_F~X{t5w$E`9V5Dt8Qa22*uhF^XQ%JwWc3L02c;#W0rp8{>5Qs; zUR|{qRJ{;bG3#%i5}U_(`2&osZtvvX9x<((BJ14WGN5!qW`)86qYYLEc9o&89K)3u ztHwkPMyoMcf#E7l)?l^{^Nm<)#*r2rZNbTHIKRuqz&_mGgVi>|rf==UjW%4}h6_zN zU4vs)xX_4ad+@7neA$UtyYYNC9`DBeow(DA+f7($#Bx2Z*5YyvuGC_=1}inVRf&V? z2p70hDDMOV8vy)=LqnpHXflc0{5Don8!K%u zC#y@4*Dom=4zP?X9aF*Ob3v60gdZrI_jk@nE#rLC0ZvwrPtqPr%yti5gS)l{Ar%O6 zKyHPf8Np_xTTyLCrxQbE7%9hKIrf(lI#7l~RhX&4{8r4@VW|N}8gZ%x=XT)cE@DPs zYs2krxV;@KJ8)wMuD9S)13sw6@iLsR#e?nmvYpVYU3k6=4|m{x3+^;vwE;J4alIOs zt8l53(3MKusK7-l+M?ml@^}|Yq8kAGXQSgm@$}|V_&RSz9!+g!sO>bhgC1JS3@c|w zRx_fu()A4tV-q8`nUT1IncU7!>l9}7N(zVM=Fvd=WKh{$aQS>t<)X53F`#tL&ps(C z8s=s9F;n(>$G3SJnl^>k68(YTQpjzPSRg5ZwgmYWG}+PZK))0H4)od4Yr~)u;}w{$ z!b}zBYOuH!hwE^xjtF>eY{$JFxZ8p|EkrK6+KO8(xYdj+b@;FhCv14%i92=pd#hH!E?y5?9M{xg3{D30<+{!y-6B+-UDWUu*+_KQ~5Xk{6rI z3GtF=(1J>QR5ssG2TfDT2&-U5R52oJ==wTFbUiDki4nJrk+hSYx|f&LCC=~nFCJFf z4h5CY5`Lg+Nm;cRP%-Ckn-CX`@N)Ju(>lEqc9No-JtDTcX{y{*rBK@8XNA;)z!Jop zVKZZ=75mNDZ$YmaJtY{hVa$oCGR#z9t^x~HgpSnUY&~wa5CGh3!rf-vap`sgZq(v( z1wOXpj0GP#2wQ*Ij5jUB^=Shh)nUCBcdD>bftzJmF2ywmVfvSB_|%F|EjW}4gLsqg zFFhI90N~G$k%wzDF)NTFP4-b1`?_KwrpCz(D`!MhG9s(#x>|;zo*C1~jB8;KnelFJ z=00)m{($1)K>LKUVm7FHA*g0aQL_|SHcK#2Ji^cIXQu7*P2A-b-Qp2dw^>u^rY=K} z6AC-QfTS3~MaU^aOA)$C&|iX{VssZ{z=}}^CQFHx3p3@It-yQ*ma6d4R@|z`gLVCHxg6+N;Q@%aMg*6Hk`HKtd)rGU)1AG176nQ=~g_d#=Q!xm15P2n|3T)am|9O zW?Z)5vKi+Juq_Hf?9Ifl|8tw^1^_?*NRhoszGA8*i5^(YRNELDJ3~t_zznZsL{`yt zwQT)XR&+f*wuzarjh(!UpV1-C?ei}l4zM307^qnasyPz$wmfQ^loTHn}&{ITQ`-*YUibFO`IWXx;oPahYB_F}V%dpHc6@9mbi;|~)p%Wl7gcy#frq77ci@f< zx2?Ek#*Jbu7vWkFt`y-?Ax>q&5#+}6@%RM?LK`07FCHvUqBO(}r_1xWL1vc9LJzSs zG^H$U8ADr653gcG)zEacG<}_~v4Iia%t_k8OYIQl^vQ~c6}E}M^0~n3!%Ct}wh&M{ zEwheGiw+2K`#2e$tfY2YOe;y>=&r3rsEYyRj|_;5pejUi0jl${w*Wo)=*q+XLJXH+ z+=599rmdK^VaA398_qbfT!lxqcvy=EwYXP~yQNrjV#R?QHe4~|ya}I}2t$A7#AoGr zQHCc@JhEwgf>QthAOJ~3K~&4rwR~L3!)Pp`1@0RF{QRW; zJ&1U;Rw!MbCk!YQC<@t1Geboz9i?lCEd;cP3R)zwaENB8qsKHd;#ydVZQQiI;+#JJ zl3|5oLQy^!NF)Ob%Cgx2`=p6xMufjMYvRei+O~VKe6DI9WSkTQi4ZCSU2HL0dD7GB@efVmKe;g*a4*2@@twm@3AMndoF) zb>dMOp1NlD2WH$W!JT5PnurO0xd4~*a4843^6=P%&x-NVgr|jgl#ly)xSNZ$9Nf;t zN*ZpZ;bt1HrDEQIB!T-cb&=c#06%5Cs3cY(EwE6aw2A_XM1Hw^Ne)kz%MK`HtE|K> zg0M11WED$a!-}qB#1IUyiCCzOpV2AJACQ|z70yXT`D{ShY=Cpx-#YGBJR&I^66f^` zvbs6x9n8dC-m%+Bh6ay_t(&zqZlP6fsxmjFgK!1%5;ti9BzchM!Qk=+HQ8v-#$YZ+ zb1{~O@q8T0$5a8P3vkqgPt91j;;9*ri(C_YA@1d4EgviSxRHnJxww`|O!Mp6c$SM7 zd3ctCM_G7~iS=~cO~dVEtR~@B0&XPWYCPJMP|!C2BDc$I0Ps`Bi$Y=r_y!dTLY$&t zi!i_>kmd3vnLJSjSC+?8mM}vdbYdG#HOo-Timqi6<OXl8T2ZxSxzW30RBAY8+N#u^Nj-Epi2J6!I^y zEwBN=PZ=*Vi7xXFHu1G(q7a8L*dholA{Y>7^7!d&Q6@{C&rn(E;S~&B4NF(U)YY)` zTNyF+%!C$BdWR@?zpP|L?qVR&F(tPh^0$o1ibwoR2PFCZ;=DdV=00}nUPi($-`MS5 zhGtS!{g&{pn>1Bh)McBMHur!M1Qa8n1o9Hd%m}c!1zFtn#mFs0Q!e_la3}+l8JNnz zWF{uEu#k(7a|xq=nS3EQiyXm-_ircBUnT+c(xMsxdI6O?iizK{A#M3xD zip9DSca6Afz*-bmqi`hx`vM^H{soqUHURhugXKZ?q=~(Q^7!G^5^bfJ@B=nsU@_l6 zpD)Sc@zc11G>%^mOIgg)l(LB0Xbn?e!!*<~jSZ~io&2nBY2l#UGODmo20Eq!>=Sb9 zxXe82S3Dv$4T=l;#W}tF41xh>Lc4EV8`aoK)-`%Y)NavKcm~_I1e)CyX174Io6?G4 z8-g9~O2_6v`=(Hvdx9CZLTt}Peg2ySu9KW87 zdx@?&J_a`pSk_@hkM$Tljm7h5JTu_29uIW5ABnr+Saa!UFyh(nzq}}D1Aw0bfG5eD z>#fY@>uM!YwNh=Rh;Riqfzr&E7x4UYxWWvsFr6z-=lbPx0!vsLCp)r|a0U8WW?VBb zZI38_zpVIx+&-cB5deS7xS#o;U(o?c;gF$ec6J%y;oy@2Tma(3bypxyPD>V(utcT=|DLDbagxogaZyED18SyI`mKF_5 z3I>EZeca40c3LMZc{e?-%{#h<8o8CEDJKV62>_H9sO(UeBBY#HN2IBQrqWGQxjDGf zGqB7644i6%6FC2HYxUI#C z2Dd_SOM^S1SP#J?6`_a0c&NmBAZ{zr$%lyYORI-&0PxcQpfV`(BzAPY*w`dC)Qj}B z;)p7twpt%wuxf zB;f(Cu()tgnA^)I80cUpwKL+j`|27gVO3rs4yw|!CD7s) z>;m8i>#sss4Z>>Nv^8#;D)-RJEy3km)TNuE>~2}bsLn-421b%G7mrhMM7aN0hu2Yf zrNfITJdePWP{PU|sBu38_tdx(gcT)Lg0P~%nm_Kya7T_cIaVaNA;b|bN@#9AzpTTC zHvsr4quPo*%Ok8;$SC+#S zXAp^jAeAFd=cvrQ=P#i-0hXBtM6a3mI2ba)nyH(_|K#Y-)otMN1tj|1^A2oDsvufSayVfw57SdrqE z5I2RmA;EP%t}<}Z2g59c@H~D=J%J4Xei{H28bub%GS-XZS|u^flDB@qP$!J65rkI? zG-doyCojZKkf1E#Doi|iK1Y_zk!EuRDI9SUM_5&wV_*uqfmT|dlBA|38uxwW0 zoDLxBLK8CcxU6VYnm;JY=@Ddh@>2J(6WUnDX5Xl-zM3-MATw22vL(<=G{{uW9~jWO z7zlR(5MJvRR^t{{?G{$)9$K*}xZFco>Je(+lw9I&E5MF)j3(fm9uKtmLWM7b@k)so z0eI$*r~Y{2her}T5MW(^yF%O%;Wh`WG~8m~1`{`Yune51qEYQ85mQJcA{+SG&36NU z|9Jq&eny6|P8_#Q8rLF?YZk{eij9q;=sJO}mLFLyh^P{TRR}diV!%_GdCFpLU=dGV z$P=e=xZ!klE<2-BTzEiQe9({Z1LFbC8D;5gpmSE?oRQn7{H+87egy}_Ieo&ceS*|I zyo5Hkv58o89ac#TvQiaATNEX3fflzQJF#3sRYvTx53MGosdjmTYGTW!rW%?mx6sN> zstS*w3Xh;NPqo7%#_Vn?z>W+|$74ARpDFQ`3~wa(OoA7FcrL+HJ|3|Nt#fdXg}Xjj zqvAFRD_d}D3vRmO7I0Jva|(sUqy92Vq8lFIr;Qicn=A^a>uX&AY?H)o7ss}UVh9XG z(e(mDozPGx)YS?jYlPZzA(0r^xWN{VvV<#1W77kC{Pgt1ZQ}evKhps}(~zHe)Za0q za1sCz3^)h|WY!6pX+)a4U!2t?Oz+?(?PA9?GYoaCh-!w~MwOd9{7st_CGLS%_dpx5 zp)9xz!4*(dAfytZRYXwq0}oZuRBR5Z@K9HHs0dd;3UrW^_AL>XP3a~yWnwNC_f_~p zfG-(%&BkY3ykO#)FP?hgu@4?nuU>tMn}c@vYLBAHpGtu~A}d5JxwNjP+uDjX0u0q%9K=L6ME` zm(2DRd2tjz`f`3=zbt=1R?sgkJ}7fcD~Rde#eic*;g|`qO!*g$O7jN9S>3{nPC?3U zZhR{{x`7o{!&KY6<)$sNLU;e-O$y6qg=KS~ZIjYLY$Q;YA-J4aMHf;*DD>^Md`oD# zXGpn6NSUYFNme;2!47huousgPs4Sb~P43$=a6*q~JbdMeFUWXB#!D}}AmjOFJOLhW z!Xr041lECjaADwv+rTw31}c4GGC00m>d$V$8vy)I4;fADrM2;*o5jXfacrwNu2me@ zB93h)82C}*m_|u-y;#3h6j?2bs1$0;1kyOB7mMo6p{g?3nO%O_y^`!6ap91kZBpTK z0t)9`pmRoHp9-)}`WKB!TxozPqm!SyN06|cAKl2&)v!YCG)a>qlHaC$Cax;>P zFqDdGDtu1H*T5IRYd5?Ao&(Q-ryxjp1U&c=gSRAHNW@Cl6)eZ5FmD-J6a2*PP3 zZ!+17EDU4BH44)@L>XP8y!}%1L82sTn^D?l0`1cQ*2w_tq`ZX4gT*<0qRcK~+FoHo zo5$hKKYTsH+-iC(hO^)w1UGqI||Xm_hJyUiuymLFbu5)8ZnUI8ybknqgK zz`tPNhWo%89q(=PY8elRD&kXVlnnsh6?wP{LH;SMh$^0;QDkfs$25y#2@-^{Eh2(} z?UK0dlKAb?*zJiMBgA%7xS6^#KtO563LfGRp+sj+63txqO5Ll{-A%!Sb$|R z&^i%l9wjNIq;bqfx%mEKmoVDSXH|nJ1*OPDkLPG>{{T6RDZlP>Zm`H;yp zGVz9FLR-kBEuJL8y?BzyR00ey7R8%O_7ReOr6iiaCqwDM(s*(rJb3yof;bOR@)mKr znz34yK!NVH>{B=v>D6$kqfC!(4gQOk|2d-D1OQ|oGTECx>0B(!XbgNNf>SyaFsL?^#V>r1n{rRjS`sk=pKdnFm2(zHF|#2tdz7M^}9PpqR+859Bl zpMMPig-jw7?uA78(?9SZZaiG_B$LQAvJaO`^CQuNwlE{zdGT(-H24=HvSxFBFRSTD z!0z*^rgOnnhh?=R>^&`>hw|OeDzHK(qM(;93_f!skXUh0jz8DUzcNlfh}?ZPG`U>F zlz4Cc$H@ma0Qjp0j!dGmC>)Kq+{h2fmW0~bno63siWy$RiKyj8)$#NVJbe={s+up0 zVS2GB|MqF)L?%u3i)9)c1j&0uDSL(KoubTr;*4%_W)G40rtJ|W?Ie`EOPtguN!%uk zYZmA#_yP@$?E4k~;!5%&te-?7yF^$&0l=S{fPcW_kN=7Mqr8aUh3-#c>)mqpX`Q09PH{@RD5*`9uw4|t zO&H%QOlalDH3`BjT$aB#G1+^Q|Hb-$0N@Af|I;481;BsyyO2m^A2Kb#lc9HG$HPB= zlfIUc*Trr;OfWEfE41%Q@ZR%5E$;>+pl9897Cx-W*=`xF6DOtR0Ac zSei1H*hr_)h=(lyiPySTR!d^&`mOA^?cDgC1O|!i!o=Oeq;_#an;?FhAfZ(l+aio> zCZ>O5Jzts5@!@!RQhp46ewhCMA^`q844%Zn2z2!pMhrNKP!w#6uJ_LBU{y^?x<64J zycIffE2RIby6d89&w16}i|X#{n!%fzu@&viy~tzF4X0ik&%807colv4nPKLUeq=SW zayT$Fnd8eQYF|IQmH#K&@Bsg%;S)*3Mx7xhI$9gA2+rXuEj~)Scd)}J*h=?HrTL2g zv&x_cl|++JR7G^+;F)cl*d6@1Hh$a=e(ZLBObaijksH^{i*4e^HVKW5{D@MnP)j3w zlRYRz9riy0;J=iBQb=AxPgc}scDx%m32Lin{5D$pZl?X9u=8Bd!4>W3isry_=>E&< zo=;VS%i4+i5lb%(CqIul^Csr>o0v0ijHlm3AOFm-^h`hh#BktdWc_S#L?+jp`FGtW z+W_FdGJNC!xjZ!`IWsLiE;c4CIwT-kC^GtrV!ilb-abMK+3U~5!6Y9NSL3ZR({+t3 zLo3U;jUCgt0ZA-ho|h+uMDilq$z-oT0lx~&qHNX*pZt#Nnj^KlH;bUC1-Xp()Th_cG6A#oNXrp_FqO`T5J|`VP0;u-oWMZ$baZb z{ly}lKTahv*z`zUcv?YRk~KWa?5`~rs0+9O$xMO9$A?cLf*%r@u=uwVKlR@w0Dr5= zl}z%al4%kWCwdb%75p^F3*FoimnH{X;Hb(9~j&oGhxa7a{v|{gv%8rkM_M8pe z_i@nvOCf_-LI*xo_g)MhysDnM6TbLFxAa6mx2_wy8kW~AWeC0h4)@T!V*rrJMASj0 z`pINQgCRRBGcPwcD>E}8K0ZvVRR#t52Ly;E5;lk9OQ%!4z28#d@kb(#3h?$8i^V#f zt}s8(Zncy-?4@>FQDJ^!LY&OckLu<1hyRX7r;Ej6i9|x9)Bo%b#^do)5)#cOQ(-|~ zQc7%Obf{7-=kPdW>W_7#A13>M!@%Dd0Fvu{NK`UiPT?fDbJM^{gRHznv|~5J`aTYI zcKQcLGbuhB9^hxSema98@$*wCmFkd?g!uTP{CtPqX1Cc&ii&b`a?(;#Qd3gWQd5$X z5@Tax!XqMrgM(x;nMfoS3Wa=ufG-dTL?WrwFGQ_QN=z^n7C7y;GN+@mthBPsX}4Jl z3-aP(W8`u)$miHVId1}GF>K0g1k^%E{dp-|-IX4@_15|b%CDa8;T zr4Cd{1Y#dwpSKg|+eYsX`cnnazhNNcNg`1g6lMsCo8->R06!i61?~y0zUE2c)3gNWI?;ghRjgP&?D`U66e zI8+}BF@yY_r2)Tu58y!}F<7jy@Nlcm*3#0lb?a8A!(l2cEGj6lmXs8mOc`nE@v(8K zDJjLKLc7)Muv+X^i@CU{upmD-J2NXIJu@T2bxqIC%E-&jF_{Xj7PH-Eb=s{?yUjsd ztz}Mod8yNBw-w~&CMPB)CnRKNW)u|`6cKNdoe&o<^^;Pl)PGsD^GA7z2Z_vLv!nI8 z5|hbpvy>E>(o&LR42B4;CRC-CN+jOC|0;MO4E;x2cYXc8XWaprI4+hMOyVbP5@dj% z0eR7uxE7jYTwFElS2iiJ9Tb}Sd3n8T>xi)Vn0(-hYJ62YeLrIUVbtL#`s2?H$DSLG zJ~J#miX6KYw(W?@SR$bFy#9(@>%UdMd;olCG`(J5Rax27-96CXzpb^UtkjvCo0F5B zRa97zmzxtG8*9)TGSbtm=Hj;jz0G2_TP+TorPOXMb=b-r_A-avX}8&}7E4J{u_@nF zkZ&r;GZo|)HWlP#XJ@3Phlhu=*zC92 z_7)Gq^vM*8w~r5##T1K#TCFA}DY3XP-(j=??%i$&>eXab?k-y#7pDx=Z3|{x{3QyEr(Un zMLZhcYs-HVv453*;Q&y)sDVmlWkp3(V?$Y4so8AK&&$b3Pft!x&dkir&&keAPs>P6 zEi51`&tbD#ONwn)!unkRSe-Vj!)mdbi-~F3lj7r(;^PwI;}YWH;$nwj)NlHpjP0h{9G#8nOm)mVNa|sdD=jJ3QCkF=yGZ;)3i^UfRq*AFeNU70; zMjLcViScPE$;pWcru^Jehn)Z*AtByqj59{Z8lz*Q4UyqtiaL8VZ%jbC0d_28KTU==TZO4#GqtNAKj=_VU?#|CbfO%7VtG8`Tv~|gU z>!Q4B*57_eRCGX4G{~=*lJ5CXIkK#oz8A6ZF!JzI-LV(?<1eF+J~u2p){otZ+IB)6 zUn=A&dYqZ*^sL1H(=)}at%#4ietjxlK zyoS2Eoo#J(wY8R#;)2{vf57v8hRk8R@B68R^*>X=$m+aj`M+u`wm4!g8mhC_g_XF(Dx~=B)_8^g2VNE<98l z6cp$m;1>`i(}bxLW8*VZ(sD8~v(nRJV~pCcP`N_J;c=+mUVqziyN9bt<;A10wPbGW z7Jiz$U%q>EBcW+@R4fPEsQ(<~~YD#i)ejdSqsUR;e zJ1a9iJta9gAwE7XCMH_1i*(t#R-;j?R6#+(N~JPTp$G^Fh=_==SS(w&Zf$C4tlzrT zX(ufHtpjn|tYvna!)7%X7Zn#26z1jTp}5BlYpov8hSP*_j#nxj9Aod4+koC8i>q z*;;HWPD)7%2$K6Se76ugQ~t(7O7S843aM-jg>CQ@rfv!=qZagXt7iSS9`>(Ukd;qM ztweszEgIz4&dK^dRZZQ~&OeM?dJ=W`nf}N#{rqG7p?gsSH^O&)q{-PS^NXTWd`Me< zZaewK0`OKG4ks`$kS7pOyu3(cGKoy4dV7l{5?xeOa#E7TVyP%EFDNKTNKA|&K8*44 zap`HPS?TF%DJjW`3Gp#8(K^EJwHi&3GSFWp6A6Vp9*@an`qF4VzP>CDCnPkaxVX5p zv$K8Iu9})^hs~awn^TaN=gJ34iVN}!a}x&e9zh4{S8W+~20Pfd!Ck26La4LZWZgoTG|wc%l5;aY8kCNx5; z)kQ?cL>rP56Ef3Nb6jb9PIgvdK|yg*v8C8jkXsO@4g2$j*S|_63Yp|XA~Q(d0tzdL z;-BOlTf?yqi7O|h)pLH8b5h5IxM)aF(8nvE6z%#zF>)hx=6?A6qsYaly2U3^GY=xi z*24OiwQXlZtOw+pOg2sUFID58MZahO$VBIeLLoLx{h^BTwhtf_3KA0&91eSSPEL44 zL}X-SjL{fFlq_Qs;$q`sV`HKX`lu*PXsAM=5Q#)A7R$@qoAhH1=WX?d>g^pA9Bi}M z8tUtv4o6;YZfbIBLPBC(e0)|$hS^kDn46oPk{llw8*R|(q9P+*R4EP%E-z{&InQ{DPI2#n{>&9YbCO9*NSeE7l|(R4oYOk zmvc>BV#lboY*K0;6BhOJaymKrJ>1F(zwJi?d(NrHZ)v9Qh0Q#On7SW6zNQ_x5!Q80 zQ!}qrrLakqpR+&kiw5977M;nAi;F8RE;bsCQa?XGnJhd!+(5+lI=#zQwHi&JLc!zl zd}uVPmlv5rbYlPb9Y|!dKqyR0O|7h`FclWY5+-PjiH?bOeI&-mrzIsN#m5_?4N(z< zDTjop0|ONjsg%d(^Y}a>304MbwVG(XJ|iuy%wel4bC#G2(^FH727P3hRuiHMQ3b1m zgMyWTfdK(>nM^8?NJJuuL=qsE2L%SHgH>9!ns5u+urQ5Q5vbsc_CE^$kj5Mw&RWrJRIHPIMV3(##6YVyfa8x*Tp! zow#;5VDBka@5dp1pM>oDD75WNh@~$;u4De8r2DhzT?D{`L}Ibov2k%3>FIL0oJ^rm zyu3JEu0o+utJNyCIv_AmC=~ko_>jot|7e*I#mg%sG_<6oB)^~_Osn;m%l+l@z#yec zr3wjAhlgn+!oniLwP6}fh+3^sD0qCnFO5c~QhjK&kdTn{jP(4xyrROw@=`~2B{BDx z6cwZ-Cg^mLp`jtF;2=eS+)pYI2m~AsheoG)dwWy8yeL#Eg-WH-Xaa%2UoKNB6u|+? zAbFrzB=%+azO_p-mF(^7<3snQc>RRM9iBfOfFD{~6tV}2yxG;$AbF9!=oDWzl_Bt= z3%z{UREjUzgX~H2BKa~Y0)L+%9V0x2qyPWgJC~j&q9_1w=$t$6SD&;+T8p%Rks^ea zL@Wg)8X*;g5+BhOo3h~lugr9KRD}hKjL~!bvx@Xn~U>v^_g0=I5`=Hq2oA)WkszJ`c=nXe6^-+`k|l6X7l-cCX+F3`>He+ z?N9#If3s7+64Qm~jO@X3PnoLYYSFYQ-1wSuU8&fvd#UERpt^25P7nm?bm;lf4l4dDnuz1N?NqeY++`4&QMC1Yy|ic27=D zdcEH3VY|fP{)fHyZ`azbxq3a9%UO;+nv?%&=t@g;<9>%h0{l63SgaDS{*npT*cY_(detEahh0aDS{dkuaUERI7zTbjB^0i__E5GuTqG==^*dA%p~Y zloA-GXyDP#2)SPftN;l+$EIHrw9 zie4ZjfPkVI4hbNj=mtds2q@a2kpKdUc>pAUfMOs92_T@D2txu0C`KZY00N4cU?hM5 zL-9zU#yt&qD@5}5fDjS@2_PV>07w7oq5D*GsNB{w$5{v{85LzLS00KfU5E4K@Xof=q2ngMvNB{w$9U2KBAj|_G0R)7B z7$kszFcF3X5D-QpkpKe1OfV7% { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, projectId, threadId, minStartTime, maxStartTime } = + await setUpProjectWithThread(client); + try { + let matched: { thread_id?: string } | undefined; + await waitUntil( + async () => { + const page = await client.threads.query({ + project_id: projectId, + page_size: 10, + min_start_time: minStartTime, + max_start_time: maxStartTime, + }); + matched = page.items.find((t) => t.thread_id === threadId); + return matched !== undefined; + }, + 90_000, + 3_000, + ); + expect(matched?.thread_id).toBe(threadId); + } finally { + await deleteProject(client, projectName); + } +}); + +test("threads.listTraces returns traces belonging to the thread", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, projectId, threadId } = + await setUpProjectWithThread(client); + try { + let itemCount = 0; + await waitUntil( + async () => { + const page = await client.threads.listTraces(threadId, { + project_id: projectId, + page_size: 10, + }); + itemCount = page.items.length; + return itemCount > 0; + }, + 60_000, + 3_000, + ); + expect(itemCount).toBeGreaterThan(0); + } finally { + await deleteProject(client, projectName); + } +}); + +// V15 answers /v2/threads/{id}/stats with 501 Not Implemented. +requiresV2.test( + "threads.stats returns a well-formed stats response", + async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, projectId, threadId } = + await setUpProjectWithThread(client); + try { + const stats = await client.threads.stats(threadId, { + selects: ["TURNS"], + session_id: projectId, + }); + expect(stats).toBeDefined(); + } finally { + await deleteProject(client, projectName); + } + }, +); diff --git a/src/lib/tests/traceable.test.ts b/src/lib/tests/traceable.test.ts new file mode 100644 index 0000000..81b9fba --- /dev/null +++ b/src/lib/tests/traceable.test.ts @@ -0,0 +1,3565 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { jest, expect, test, describe, it } from "@jest/globals"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { RunTree, RunTreeConfig } from "../run_trees.js"; +import { + _LC_CONTEXT_VARIABLES_KEY, + _processingFailed, +} from "../singletons/constants.js"; +import { + ROOT, + traceable, + withRunTree, + isTraceableFunction, +} from "../traceable.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { mockClient } from "./utils/mock_client.js"; +import { + Client, + LS_MESSAGE_VIEW_EXCLUDE, + overrideFetchImplementation, +} from "../index.js"; +import { + AsyncLocalStorageProviderSingleton, + getCurrentRunTree, +} from "../singletons/traceable.js"; +import { KVMap } from "../schemas.js"; + +const SECRET = "super-secret-value"; + +test("basic traceable implementation", async () => { + const { client, callSpy } = mockClient(); + const llm = traceable( + async function* llm(input: string) { + const response = input.repeat(2).split(""); + for (const char of response) { + yield char; + } + }, + { client, tracingEnabled: true }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of llm("Hello world")) { + // pass + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["llm:0"], + edges: [], + }); + expect(callSpy.mock.calls.length).toBe(2); +}); + +test("404s should only log, not throw an error", async () => { + const overriddenFetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + statusText: "Expected test error", + json: () => Promise.resolve({}), + text: () => Promise.resolve("Expected test error."), + }), + ); + overrideFetchImplementation(overriddenFetch); + const client = new Client({ + apiUrl: "https://foobar.notreal", + autoBatchTracing: false, + }); + const llm = traceable( + async function* llm(input: string) { + const response = input.repeat(2).split(""); + for (const char of response) { + yield char; + } + }, + { client, tracingEnabled: true }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of llm("Hello world")) { + // pass + } + expect(overriddenFetch).toHaveBeenCalled(); +}); + +test("nested traceable implementation", async () => { + const { client, callSpy } = mockClient(); + + const llm = traceable(async function llm(input: string) { + return input.repeat(2); + }); + + const str = traceable(async function* str(input: string) { + const response = input.split("").reverse(); + for (const char of response) { + yield char; + } + }); + + const chain = traceable( + async function chain(input: string) { + const question = await llm(input); + + let answer = ""; + for await (const char of str(question)) { + answer += char; + } + + return { question, answer }; + }, + { client, tracingEnabled: true }, + ); + + const result = await chain("Hello world"); + + expect(result).toEqual({ + question: "Hello worldHello world", + answer: "dlrow olleHdlrow olleH", + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["chain:0", "llm:1", "str:2"], + edges: [ + ["chain:0", "llm:1"], + ["chain:0", "str:2"], + ], + }); + expect(callSpy.mock.calls.length).toBe(6); +}); + +test("nested traceable passes through LangChain context vars", (done) => { + const alsInstance = AsyncLocalStorageProviderSingleton.getInstance(); + + alsInstance.run( + { + [_LC_CONTEXT_VARIABLES_KEY]: { foo: "bar" }, + } as any, + // eslint-disable-next-line @typescript-eslint/no-misused-promises + async () => { + try { + expect( + (alsInstance.getStore() as any)?.[_LC_CONTEXT_VARIABLES_KEY]?.foo, + ).toEqual("bar"); + const { client, callSpy } = mockClient(); + + const llm = traceable(async function llm(input: string) { + expect( + (alsInstance.getStore() as any)?.[_LC_CONTEXT_VARIABLES_KEY]?.foo, + ).toEqual("bar"); + return input.repeat(2); + }); + + const str = traceable(async function* str(input: string) { + const response = input.split("").reverse(); + for (const char of response) { + yield char; + } + expect( + (alsInstance.getStore() as any)?.[_LC_CONTEXT_VARIABLES_KEY]?.foo, + ).toEqual("bar"); + }); + + const chain = traceable( + async function chain(input: string) { + expect( + (alsInstance.getStore() as any)?.[_LC_CONTEXT_VARIABLES_KEY]?.foo, + ).toEqual("bar"); + const question = await llm(input); + + let answer = ""; + for await (const char of str(question)) { + answer += char; + } + + return { question, answer }; + }, + { client, tracingEnabled: true }, + ); + + const result = await chain("Hello world"); + + expect(result).toEqual({ + question: "Hello worldHello world", + answer: "dlrow olleHdlrow olleH", + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["chain:0", "llm:1", "str:2"], + edges: [ + ["chain:0", "llm:1"], + ["chain:0", "str:2"], + ], + }); + expect( + (alsInstance.getStore() as any)?.[_LC_CONTEXT_VARIABLES_KEY]?.foo, + ).toEqual("bar"); + done(); + } catch (e: any) { + done(e); + } + }, + ); +}); + +test("trace circular input and output objects", async () => { + const { client, callSpy } = mockClient(); + const a: Record = {}; + const b: Record = {}; + a.b = b; + b.a = a; + const llm = traceable( + async function foo(_: Record) { + return a; + }, + { client, tracingEnabled: true }, + ); + + const input = { + a, + a2: a, + normalParam: { + test: true, + }, + }; + await llm(input); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["foo:0"], + edges: [], + data: { + "foo:0": { + inputs: { + a: { + b: { + a: { + result: "[Circular]", + }, + }, + }, + a2: { + b: { + a: { + result: "[Circular]", + }, + }, + }, + normalParam: { + test: true, + }, + }, + outputs: { + b: { + a: { + b: { + result: "[Circular]", + }, + }, + }, + }, + }, + }, + }); +}); + +test("passing run tree manually", async () => { + const { client, callSpy } = mockClient(); + const child = traceable( + async (runTree: RunTree, depth = 0): Promise => { + if (depth < 2) { + return child(runTree, depth + 1); + } + return 3; + }, + { name: "child" }, + ); + + const parent = traceable( + async function parent(runTree: RunTree) { + const first = await child(runTree); + const second = await child(runTree); + + return first + second; + }, + { client, tracingEnabled: true }, + ); + + await parent(ROOT); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "parent:0", + "child:1", + "child:2", + "child:3", + "child:4", + "child:5", + "child:6", + ], + edges: [ + ["parent:0", "child:1"], + ["child:1", "child:2"], + ["child:2", "child:3"], + ["parent:0", "child:4"], + ["child:4", "child:5"], + ["child:5", "child:6"], + ], + }); +}); + +describe("distributed tracing", () => { + it("default", async () => { + const { client, callSpy } = mockClient(); + const child = traceable( + async (depth = 0): Promise => { + if (depth < 2) return child(depth + 1); + return 3; + }, + { name: "child" }, + ); + + const parent = traceable(async function parent() { + const first = await child(); + const second = await child(); + return first + second; + }); + + const clientRunTree = new RunTree({ + name: "client", + client, + tracingEnabled: true, + }); + await clientRunTree.postRun(); + + // do nothing with the client run tree + + await clientRunTree.patchRun(); + + const response = await withRunTree(clientRunTree, () => parent()); + expect(response).toBe(6); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "client:0", + "parent:1", + "child:2", + "child:3", + "child:4", + "child:5", + "child:6", + "child:7", + ], + edges: [ + ["client:0", "parent:1"], + ["parent:1", "child:2"], + ["child:2", "child:3"], + ["child:3", "child:4"], + ["parent:1", "child:5"], + ["child:5", "child:6"], + ["child:6", "child:7"], + ], + }); + }); + + it("sync function", async () => { + const { client, callSpy } = mockClient(); + const child = traceable( + async (depth = 0): Promise => { + if (depth < 2) return child(depth + 1); + return 3; + }, + { name: "child" }, + ); + + const parent = traceable(async function parent() { + const first = await child(); + const second = await child(); + return first + second; + }); + + const clientRunTree = new RunTree({ + name: "client", + client, + tracingEnabled: true, + }); + await clientRunTree.postRun(); + await clientRunTree.patchRun(); + + let promiseOutside: Promise = Promise.resolve(); + + const response = await withRunTree(clientRunTree, () => { + promiseOutside = parent(); + }); + + expect(response).toBeUndefined(); + await promiseOutside; + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "client:0", + "parent:1", + "child:2", + "child:3", + "child:4", + "child:5", + "child:6", + "child:7", + ], + edges: [ + ["client:0", "parent:1"], + ["parent:1", "child:2"], + ["child:2", "child:3"], + ["child:3", "child:4"], + ["parent:1", "child:5"], + ["child:5", "child:6"], + ["child:6", "child:7"], + ], + }); + }); +}); + +describe("async generators", () => { + test("success", async () => { + const { client, callSpy } = mockClient(); + + const iterableTraceable = traceable( + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + }, + { client, tracingEnabled: true }, + ); + + const numbers: number[] = []; + for await (const num of iterableTraceable()) { + numbers.push(num); + } + + expect(numbers).toEqual([0, 1, 2, 3, 4]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["giveMeNumbers:0"], + edges: [], + data: { + "giveMeNumbers:0": { + outputs: { outputs: [0, 1, 2, 3, 4] }, + }, + }, + }); + }); + + test("error", async () => { + const { client, callSpy } = mockClient(); + const throwTraceable = traceable( + async function* () { + for (let i = 0; i < 5; i++) { + yield i; + if (i === 2) throw new Error("I am bad"); + } + }, + { name: "throwTraceable", client, tracingEnabled: true }, + ); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of throwTraceable()) { + // pass + } + }).rejects.toThrow("I am bad"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["throwTraceable:0"], + edges: [], + data: { + "throwTraceable:0": { + error: "Error: I am bad", + outputs: { outputs: [0, 1, 2] }, + }, + }, + }); + }); + + test("break", async () => { + const { client, callSpy } = mockClient(); + const iterableTraceable = traceable( + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + }, + { client, tracingEnabled: true }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of iterableTraceable()) { + break; + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["giveMeNumbers:0"], + edges: [], + data: { + "giveMeNumbers:0": { + outputs: { outputs: [0] }, + error: "Cancelled", + }, + }, + }); + }); + + // https://github.com/nodejs/node/issues/42237 + test("nested invocation", async () => { + const { client, callSpy } = mockClient(); + const child = traceable( + async function* child() { + for (let i = 0; i < 5; i++) yield i; + }, + { name: "child", client, tracingEnabled: true }, + ); + + const parent = traceable( + async function* parent() { + for await (const num of child()) yield num; + for await (const num of child()) yield 4 - num; + }, + { name: "parent", client, tracingEnabled: true }, + ); + + const numbers: number[] = []; + for await (const num of parent()) { + numbers.push(num); + } + + expect(numbers).toEqual([0, 1, 2, 3, 4, 4, 3, 2, 1, 0]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parent:0", "child:1", "child:2"], + edges: [ + ["parent:0", "child:1"], + ["parent:0", "child:2"], + ], + }); + }); + + test("in promise success", async () => { + const { client, callSpy } = mockClient(); + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + return giveMeNumbers(); + } + + const it = traceable(giveMeGiveMeNumbers, { client, tracingEnabled: true }); + + const numbers: number[] = []; + for await (const num of await it()) { + numbers.push(num); + } + + expect(numbers).toEqual([0, 1, 2, 3, 4]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["giveMeGiveMeNumbers:0"], + edges: [], + data: { + "giveMeGiveMeNumbers:0": { + outputs: { outputs: [0, 1, 2, 3, 4] }, + }, + }, + }); + }); + + test("in promise error", async () => { + const { client, callSpy } = mockClient(); + + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + if (i === 2) throw new Error("I am bad"); + } + } + return giveMeNumbers(); + } + + const it = traceable(giveMeGiveMeNumbers, { client, tracingEnabled: true }); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of await it()) { + // pass + } + }).rejects.toThrow("I am bad"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["giveMeGiveMeNumbers:0"], + edges: [], + data: { + "giveMeGiveMeNumbers:0": { + error: "Error: I am bad", + outputs: { outputs: [0, 1, 2] }, + }, + }, + }); + }); + + test("in promise break", async () => { + const { client, callSpy } = mockClient(); + + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + return giveMeNumbers(); + } + + const it = traceable(giveMeGiveMeNumbers, { client, tracingEnabled: true }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of await it()) { + break; + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["giveMeGiveMeNumbers:0"], + edges: [], + data: { + "giveMeGiveMeNumbers:0": { + outputs: { outputs: [0] }, + error: "Cancelled", + }, + }, + }); + }); + + // https://github.com/nodejs/node/issues/42237 + test("in promise nested invocation", async () => { + const { client, callSpy } = mockClient(); + const child = traceable(async function child() { + async function* child() { + for (let i = 0; i < 5; i++) yield i; + } + return child(); + }); + + async function parent() { + async function* parent() { + for await (const num of await child()) yield num; + for await (const num of await child()) yield 4 - num; + } + return parent(); + } + + const it = traceable(parent, { client, tracingEnabled: true }); + + const numbers: number[] = []; + for await (const num of await it()) { + numbers.push(num); + } + + expect(numbers).toEqual([0, 1, 2, 3, 4, 4, 3, 2, 1, 0]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parent:0", "child:1", "child:2"], + edges: [ + ["parent:0", "child:1"], + ["parent:0", "child:2"], + ], + }); + }); + + test("readable stream", async () => { + const { client, callSpy } = mockClient(); + + const stream = traceable( + async function stream() { + const readStream = new ReadableStream({ + async pull(controller) { + for (let i = 0; i < 5; i++) { + controller.enqueue(i); + } + controller.close(); + }, + }); + + return readStream; + }, + { client, tracingEnabled: true }, + ); + + const numbers: number[] = []; + for await (const num of (await stream()) as unknown as AsyncGenerator) { + numbers.push(num); + } + + expect(numbers).toEqual([0, 1, 2, 3, 4]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["stream:0"], + edges: [], + data: { + "stream:0": { + outputs: { outputs: [0, 1, 2, 3, 4] }, + }, + }, + }); + }); + + test("iterable with props", async () => { + const { client, callSpy } = mockClient(); + + const iterableTraceable = traceable( + function iterableWithProps() { + return { + *[Symbol.asyncIterator]() { + yield 0; + }, + prop: "value", + }; + }, + { + client, + tracingEnabled: true, + }, + ); + + const numbers: number[] = []; + const iterableWithProps = await iterableTraceable(); + for await (const num of iterableWithProps) { + numbers.push(num); + } + + expect(numbers).toEqual([0]); + + expect(iterableWithProps.prop).toBe("value"); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["iterableWithProps:0"], + edges: [], + data: { + "iterableWithProps:0": { + outputs: { outputs: [0] }, + }, + }, + }); + }); + + test("early termination triggers finally block cleanup", async () => { + const { client } = mockClient(); + const cleanupOrder: string[] = []; + + async function* generator(name: string) { + try { + cleanupOrder.push(`${name}: In try block`); + yield `${name}: yield 1`; + yield `${name}: yield 2`; + } catch { + cleanupOrder.push(`${name}: caught exception`); + } finally { + cleanupOrder.push(`${name}: In finally block`); + } + } + + const traceableGenerator = traceable(generator, { + name: "traceable_test", + client, + tracingEnabled: true, + }); + + // Break early to trigger cleanup + for await (const _ of traceableGenerator("test")) { + break; + } + + // Verify that finally block was called even though we broke early + expect(cleanupOrder).toEqual([ + "test: In try block", + "test: In finally block", + ]); + }); + + test("early termination with throw triggers finally block cleanup", async () => { + const { client } = mockClient(); + const cleanupOrder: string[] = []; + + async function* generator(name: string, shouldThrow: boolean) { + try { + cleanupOrder.push(`${name}: In try block`); + yield `${name}: yield 1`; + if (shouldThrow) { + throw new Error("Test error"); + } + yield `${name}: yield 2`; + } catch (ex) { + cleanupOrder.push(`${name}: caught exception`); + throw ex; + } finally { + cleanupOrder.push(`${name}: In finally block`); + } + } + + const traceableGenerator = traceable(generator, { + name: "traceable_test_throw", + client, + tracingEnabled: true, + }); + + // Error should propagate and finally should still run + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of traceableGenerator("test", true)) { + // pass + } + }).rejects.toThrow("Test error"); + + // Verify that finally block was called even though an error was thrown + expect(cleanupOrder).toEqual([ + "test: In try block", + "test: caught exception", + "test: In finally block", + ]); + }); + + test("parent run ends immediately on error without waiting for child runs", async () => { + const { client, callSpy } = mockClient(); + const parentId = uuidv4(); + + // Create a child that never completes (simulating long-running streaming) + const neverEndingChild = traceable( + async function* neverEndingChild() { + // eslint-disable-next-line no-constant-condition + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)); + yield "chunk"; + } + }, + { name: "never_ending_child", client, tracingEnabled: true }, + ); + + const parentWithError = traceable( + async function parentWithError() { + // Start child operation that will never finish + const childIterator = neverEndingChild()[Symbol.asyncIterator](); + void childIterator.next(); // Start the child + + // Throw error immediately + throw new Error("Parent error"); + }, + { name: "parent", client, tracingEnabled: true, id: parentId }, + ); + + const startTime = Date.now(); + + // Error should propagate immediately + await expect(parentWithError()).rejects.toThrow("Parent error"); + + // Parent run should be ended quickly, not waiting for child + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.data["parent:0"].error).toContain("Parent error"); + expect(tree.data["parent:0"].end_time).toBeDefined(); + + // Child run should NOT have ended (since it never finishes and parent didn't wait) + expect(tree.data["never_ending_child:1"]).toBeDefined(); + expect(tree.data["never_ending_child:1"].end_time).toBeUndefined(); + + const elapsedTime = Date.now() - startTime; + + // Parent run should end quickly, proving we didn't wait for the never-ending child + expect(elapsedTime).toBeLessThan(200); + }); + + test("generator run ends immediately on error without waiting for child runs", async () => { + const { client, callSpy } = mockClient(); + + // Create a child that never completes + const neverEndingChild = traceable( + async function* neverEndingChild() { + // eslint-disable-next-line no-constant-condition + while (true) { + await new Promise((resolve) => setTimeout(resolve, 100)); + yield "chunk"; + } + }, + { name: "never_ending_child", client, tracingEnabled: true }, + ); + + const generatorWithError = traceable( + async function* generatorWithError() { + // Start child operation that will never finish + const childIterator = neverEndingChild()[Symbol.asyncIterator](); + void childIterator.next(); // Start the child + + yield "first"; + + // Throw error after first yield + throw new Error("Generator error"); + }, + { name: "generator", client, tracingEnabled: true }, + ); + + const startTime = Date.now(); + + // Error should propagate immediately + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of generatorWithError()) { + // pass + } + }).rejects.toThrow("Generator error"); + + // Generator run should be ended quickly, not waiting for child + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.data["generator:0"].error).toContain("Generator error"); + expect(tree.data["generator:0"].end_time).toBeDefined(); + + // Child run should NOT have ended (since it never finishes and generator didn't wait) + expect(tree.data["never_ending_child:1"]).toBeDefined(); + expect(tree.data["never_ending_child:1"].end_time).toBeUndefined(); + + const elapsedTime = Date.now() - startTime; + + // Generator run should end quickly, proving we didn't wait for the never-ending child + expect(elapsedTime).toBeLessThan(200); + }); +}); + +describe("deferred input", () => { + test("generator", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function* parrotStream(input: Generator) { + for (const token of input) { + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const inputGenerator = function* () { + for (const token of "Hello world".split(" ")) { + yield token; + } + }; + + const tokens: string[] = []; + for await (const token of parrotStream(inputGenerator())) { + tokens.push(token); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["Hello", "world"] }, + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("async generator", async () => { + const { client, callSpy } = mockClient(); + const inputStream = async function* inputStream() { + for (const token of "Hello world".split(" ")) { + yield token; + } + }; + + const parrotStream = traceable( + async function* parrotStream(input: AsyncGenerator) { + for await (const token of input) { + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const tokens: string[] = []; + for await (const token of parrotStream(inputStream())) { + tokens.push(token); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["Hello", "world"] }, + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("async generator with child non generator", async () => { + const { client, callSpy } = mockClient(); + const inputStream = async function* inputStream() { + for (const token of "Hello world".split(" ")) { + yield token; + } + }; + + const parrotStream = traceable( + async function* parrotStream(input: AsyncGenerator) { + const childFn = traceable( + () => { + return "foo"; + }, + { + name: "childFn", + }, + ); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + yield childFn(); + + for await (const token of input) { + await new Promise((resolve) => setTimeout(resolve, 10)); + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const tokens: string[] = []; + for await (const token of parrotStream(inputStream())) { + tokens.push(token); + } + + expect(tokens).toEqual(["foo", "Hello", "world"]); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree).toMatchObject({ + nodes: ["childFn:0", "parrotStream:1"], + edges: [["parrotStream:1", "childFn:0"]], + data: { + "childFn:0": { + inputs: {}, + outputs: { outputs: "foo" }, + }, + "parrotStream:1": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["foo", "Hello", "world"] }, + }, + }, + }); + expect( + new Date(tree.data["parrotStream:1"].start_time!).getTime(), + ).toBeLessThan(new Date(tree.data["childFn:0"].start_time!).getTime()); + expect( + new Date(tree.data["parrotStream:1"].end_time!).getTime(), + ).toBeGreaterThan(new Date(tree.data["childFn:0"].end_time!).getTime()); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(3); + }); + + test("readable stream", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function* parrotStream(input: ReadableStream) { + for await (const token of input as unknown as AsyncGenerator) { + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const readStream = new ReadableStream({ + async start(controller) { + for (const token of "Hello world".split(" ")) { + controller.enqueue(token); + } + controller.close(); + }, + }); + + const tokens: string[] = []; + for await (const token of parrotStream(readStream)) { + tokens.push(token); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["Hello", "world"] }, + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("readable stream reader", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function* parrotStream(input: ReadableStream) { + const reader = input.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + }, + { client, tracingEnabled: true }, + ); + + const readStream = new ReadableStream({ + async start(controller) { + for (const token of "Hello world".split(" ")) { + controller.enqueue(token); + } + controller.close(); + }, + }); + + const tokens: string[] = []; + for await (const token of parrotStream(readStream)) { + tokens.push(token); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["Hello", "world"] }, + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("promise", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function* parrotStream(input: Promise) { + if (!(input instanceof Promise)) { + throw new Error("Input must be a promise"); + } + + for (const token of await input) { + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const tokens: string[] = []; + for await (const token of parrotStream( + Promise.resolve(["Hello", "world"]), + )) { + tokens.push(token); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: ["Hello", "world"] }, + outputs: { outputs: ["Hello", "world"] }, + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("promise rejection", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function parrotStream(input: Promise) { + return await input; + }, + { client, tracingEnabled: true }, + ); + + await expect(async () => { + await parrotStream(Promise.reject(new Error("Rejected!"))); + }).rejects.toThrow("Rejected!"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: { error: {} } }, + error: "Error: Rejected!", + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); + + test("promise rejection, callback handling", async () => { + const { client, callSpy } = mockClient(); + const parrotStream = traceable( + async function parrotStream(input: Promise) { + return input.then((value) => value); + }, + { client, tracingEnabled: true }, + ); + + await expect(async () => { + await parrotStream(Promise.reject(new Error("Rejected!"))); + }).rejects.toThrow("Rejected!"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parrotStream:0"], + edges: [], + data: { + "parrotStream:0": { + inputs: { input: { error: {} } }, + error: "Error: Rejected!", + }, + }, + }); + // If input is deferred, it should be sent in a single POST call + expect(callSpy.mock.calls.length).toBe(1); + }); +}); + +describe("generator", () => { + function gatherAll(iterator: Iterator) { + const chunks: unknown[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + const next = iterator.next(); + chunks.push(next.value); + if (next.done) break; + } + + return chunks; + } + + test("yield", async () => { + const { client, callSpy } = mockClient(); + + function* generator() { + for (let i = 0; i < 3; ++i) yield i; + } + + const traced = traceable(generator, { client, tracingEnabled: true }); + + expect(gatherAll(await traced())).toEqual(gatherAll(generator())); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["generator:0"], + edges: [], + data: { + "generator:0": { + outputs: { outputs: [0, 1, 2] }, + }, + }, + }); + }); + + test("with return", async () => { + const { client, callSpy } = mockClient(); + + function* generator() { + for (let i = 0; i < 3; ++i) yield i; + return 3; + } + + const traced = traceable(generator, { client, tracingEnabled: true }); + + expect(gatherAll(await traced())).toEqual(gatherAll(generator())); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["generator:0"], + edges: [], + data: { "generator:0": { outputs: { outputs: [0, 1, 2, 3] } } }, + }); + }); + + test("nested", async () => { + const { client, callSpy } = mockClient(); + + function* generator() { + function* child() { + for (let i = 0; i < 3; ++i) yield i; + } + + for (let i = 0; i < 2; ++i) { + for (const num of child()) yield num; + } + + return 3; + } + + const traced = traceable(generator, { client, tracingEnabled: true }); + expect(gatherAll(await traced())).toEqual(gatherAll(generator())); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["generator:0"], + edges: [], + data: { + "generator:0": { + outputs: { outputs: [0, 1, 2, 0, 1, 2, 3] }, + }, + }, + }); + }); +}); + +test("metadata", async () => { + const { client, callSpy } = mockClient(); + const main = traceable(async (): Promise => 42, { + client, + name: "main", + metadata: { customValue: "hello" }, + tracingEnabled: true, + }); + + await main(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["main:0"], + edges: [], + data: { + "main:0": { + extra: { metadata: { customValue: "hello" } }, + outputs: { outputs: 42 }, + }, + }, + }); +}); + +test("argsConfigPath", async () => { + const { client, callSpy } = mockClient(); + const main = traceable( + async ( + value: number, + options: { + suffix: string; + langsmithExtra?: Partial; + }, + ): Promise => `${value}${options.suffix}`, + { + client, + name: "main", + argsConfigPath: [1, "langsmithExtra"], + tracingEnabled: true, + }, + ); + + await main(1, { + suffix: "hello", + langsmithExtra: { + name: "renamed", + tags: ["tag1", "tag2"], + metadata: { customValue: "hello" }, + }, + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["renamed:0"], + edges: [], + data: { + "renamed:0": { + extra: { metadata: { customValue: "hello" } }, + tags: ["tag1", "tag2"], + inputs: { + args: [1, { suffix: "hello" }], + }, + outputs: { outputs: "1hello" }, + }, + }, + }); +}); + +test("traceable continues execution when client throws error", async () => { + const errorClient = { + createRun: jest + .fn() + .mockRejectedValue(new Error("Expected test client error") as never), + updateRun: jest + .fn() + .mockRejectedValue(new Error("Expected test client error") as never), + }; + + const tracedFunction = traceable( + async (value: number): Promise => value * 2, + { + client: errorClient as unknown as Client, + name: "errorTest", + tracingEnabled: true, + }, + ); + + const result = await tracedFunction(5); + + expect(result).toBe(10); + expect(errorClient.createRun).toHaveBeenCalled(); + expect(errorClient.updateRun).toHaveBeenCalled(); +}); + +test("traceable with processInputs", async () => { + const { client, callSpy } = mockClient(); + + type FuncInputs = { username: string; password: string }; + + const processInputs = jest.fn((inputs: FuncInputs) => { + return { ...inputs, password: "****" }; + }); + + const originalFunc = async function func(input: FuncInputs) { + // The function should receive the original inputs + expect(input.password).toBe("secret"); + return `Welcome, ${input.username}`; + }; + + let func = traceable(originalFunc, { + client, + tracingEnabled: true, + // @ts-expect-error - Should infer inputs as FuncInputs + processInputs: (inputs: { foo: string }) => { + return { ...inputs, password: "****" }; + }, + }); + + func = traceable(originalFunc, { + client, + tracingEnabled: true, + processInputs, + }); + + await func({ username: "user1", password: "secret" }); + + expect(processInputs).toHaveBeenCalledWith({ + username: "user1", + password: "secret", + }); + // Verify that the logged inputs have the password masked + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + inputs: { + username: "user1", + password: "****", + }, + outputs: { outputs: "Welcome, user1" }, + }, + }, + }); +}); + +test("traceable with processOutputs", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((_outputs: { outputs: string }) => { + return { outputs: "Modified Output" }; + }); + + const originalFunc = async function (input: string) { + return `Original Output for ${input}`; + }; + + let func = traceable(originalFunc, { + client, + tracingEnabled: true, + // @ts-expect-error - Should infer outputs as a nested object for simple string return values + processOutputs: (outputs: string) => { + return outputs; + }, + }); + + func = traceable(originalFunc, { + client, + tracingEnabled: true, + processOutputs, + }); + + const result = await func("test"); + + expect(processOutputs).toHaveBeenCalledWith({ + outputs: "Original Output for test", + }); + expect(result).toBe("Original Output for test"); + // Verify that the tracing data shows the modified output + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["originalFunc:0"], + edges: [], + data: { + "originalFunc:0": { + inputs: { input: "test" }, + outputs: { outputs: "Modified Output" }, + }, + }, + }); +}); + +test("traceable process inputs/process outputs type inference", async () => { + const { client } = mockClient(); + + const funcWithMultipleArgs = async (a: string, b: number, c: boolean) => { + return { a, b, c }; + }; + + await traceable(funcWithMultipleArgs, { + client, + processInputs: (inputs) => { + // @ts-expect-error - Should infer several inputs as an array + inputs.a; + inputs.args[0]; + if ( + typeof inputs.args[0] !== "string" || + typeof inputs.args[1] !== "number" || + typeof inputs.args[2] !== "boolean" + ) { + throw new Error("Invalid inputs"); + } + return {}; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should infer object return values unchanged + outputs.outputs; + if ( + typeof outputs.a !== "string" || + typeof outputs.b !== "number" || + typeof outputs.c !== "boolean" + ) { + throw new Error("Invalid outputs"); + } + return {}; + }, + })("a", 1, true); + + const funcWithArrayInputAndReturn = async (a: string[]) => { + a.slice(0, 1); + return ["a", "b", "c"]; + }; + + await traceable(funcWithArrayInputAndReturn, { + client, + processInputs: (inputs) => { + inputs.input[0]; + return {}; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should wrap string return values as { outputs: string } + outputs.slice; + outputs.outputs.slice(0, 1); + return {}; + }, + })(["a", "b", "c"]); + + const funcWithStringInputAndReturn = async (a: string) => { + a.slice(0, 1); + return "b"; + }; + + await traceable(funcWithStringInputAndReturn, { + client, + processInputs: (inputs) => { + // @ts-expect-error - Should infer string input as { input: string } + inputs.slice; + if (typeof inputs.input !== "string") { + throw new Error("Invalid inputs"); + } + return {}; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should infer string return value as { outputs: string } + outputs.slice; + if (typeof outputs.outputs !== "string") { + throw new Error("Invalid outputs"); + } + return {}; + }, + })("a"); + + const funcWithObjectInputAndReturn = async (a: { b: string }) => { + a.b.slice(0, 1); + return { b: "b" }; + }; + + await traceable(funcWithObjectInputAndReturn, { + client, + processInputs: (inputs) => { + // @ts-expect-error - Should infer object input directly + inputs.slice; + if (typeof inputs.b !== "string") { + throw new Error("Invalid inputs"); + } + return { b: inputs.b }; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should infer object return value directly + outputs.slice; + if (typeof outputs.b !== "string") { + throw new Error("Invalid outputs"); + } + return { b: outputs.b }; + }, + })({ b: "a" }); + + const funcWithIteratorInputAndReturn = function* (a: Iterable) { + for (const item of a) { + yield item; + } + }; + + const tracedIteratorFunc = traceable(funcWithIteratorInputAndReturn, { + client, + processInputs: (inputs) => { + // @ts-expect-error - Should infer nested input + inputs.slice; + inputs.input.slice(0, 1); + if (!Array.isArray(inputs.input)) { + throw new Error("Invalid inputs"); + } + return {}; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should infer nested return value + outputs.slice; + outputs.outputs.slice(0, 1); + if (!Array.isArray(outputs.outputs)) { + throw new Error("Invalid outputs"); + } + return {}; + }, + }); + const chunks: any[] = []; + for (const value of await tracedIteratorFunc(["a", "b", "c"])) { + chunks.push(value); + } + expect(chunks).toEqual(["a", "b", "c"]); + + const funcWithAsyncIteratorInputAndReturn = async function* ( + a: AsyncIterable, + ) { + for await (const item of a) { + yield item; + } + }; + + const tracedAsyncIteratorFunc = traceable( + funcWithAsyncIteratorInputAndReturn, + { + client, + processInputs: (inputs) => { + // @ts-expect-error - Should infer nested input + inputs.slice; + inputs.input.slice(0, 1); + if (!Array.isArray(inputs.input)) { + throw new Error("Invalid inputs"); + } + return {}; + }, + processOutputs: (outputs) => { + // @ts-expect-error - Should infer nested return value + outputs.slice; + outputs.outputs.slice(0, 1); + if (!Array.isArray(outputs.outputs)) { + throw new Error("Invalid outputs"); + } + return {}; + }, + }, + ); + const inputAsyncIterable = (async function* () { + yield "d"; + yield "e"; + yield "f"; + })(); + const chunks2: any[] = []; + for await (const value of tracedAsyncIteratorFunc(inputAsyncIterable)) { + chunks2.push(value); + } + expect(chunks2).toEqual(["d", "e", "f"]); +}); + +test("traceable with processInputs throwing error does not affect invocation", async () => { + const { client, callSpy } = mockClient(); + + const processInputs = jest.fn((_inputs: Readonly) => { + throw new Error("totally expected test processInputs error"); + }); + + const func = traceable( + async function func(input: { username: string }) { + // This should not be called + return `Hello, ${input.username}`; + }, + { + client, + tracingEnabled: true, + processInputs, + }, + ); + + const result = await func({ username: "user1" }); + + expect(processInputs).toHaveBeenCalledWith({ username: "user1" }); + expect(result).toBe("Hello, user1"); + + // Fail closed: a marker replaces the raw inputs; the run still traces. + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + inputs: { ls_error: _processingFailed("inputs", "processInputs") }, + outputs: { outputs: "Hello, user1" }, + }, + }, + }); +}); + +test("traceable with processOutputs throwing error does not affect invocation", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((_outputs: Readonly) => { + throw new Error("totally expected test processInputs error"); + }); + + const func = traceable( + async function func(input: string) { + return `Original Output for ${input}`; + }, + { + client, + tracingEnabled: true, + processOutputs, + }, + ); + + const result = await func("test"); + + expect(processOutputs).toHaveBeenCalledWith({ + outputs: "Original Output for test", + }); + expect(result).toBe("Original Output for test"); + + // Fail closed: a marker replaces the raw outputs; inputs still post. + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + inputs: { input: "test" }, + outputs: { ls_error: _processingFailed("outputs", "processOutputs") }, + }, + }, + }); +}); + +test("SECURITY: traceable processInputs failure drops the inputs (fail closed)", async () => { + const { client, callSpy } = mockClient(); + + // Meant to strip the password, but throws -- the error echoes the inputs. + const processInputs = jest.fn((inputs: Readonly) => { + throw new Error(`redactor boom on ${JSON.stringify(inputs)}`); + }); + + const login = traceable( + async function login(input: { username: string; password: string }) { + return `Welcome, ${input.username}`; + }, + { client, tracingEnabled: true, processInputs }, + ); + + const result = await login({ + username: "user1", + password: SECRET, + }); + + // The user's function is unaffected by the tracing failure. + expect(result).toBe("Welcome, user1"); + + // Fail closed: no cleartext is uploaded, not even via the error text... + // ...and the gap is explicit rather than silent. + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + // The mock body is a byte array, so assert against the decoded tree. + expect(JSON.stringify(tree)).not.toContain(SECRET); + expect(tree.nodes).toEqual(["login:0"]); + expect(tree.data["login:0"].inputs).toEqual({ + ls_error: _processingFailed("inputs", "processInputs"), + }); +}); + +test("SECURITY: traceable async processInputs rejection drops the inputs (fail closed)", async () => { + const { client, callSpy } = mockClient(); + + // Async redactor that rejects -- the sync try/catch never saw this path. + const processInputs = jest.fn(async (inputs: Readonly) => { + throw new Error(`async redactor boom on ${JSON.stringify(inputs)}`); + }); + + const login = traceable( + async function login(input: { username: string; password: string }) { + return `Welcome, ${input.username}`; + }, + { client, tracingEnabled: true, processInputs }, + ); + + const result = await login({ username: "user1", password: SECRET }); + + expect(result).toBe("Welcome, user1"); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + // The mock body is a byte array, so assert against the decoded tree. + expect(JSON.stringify(tree)).not.toContain(SECRET); + expect(tree.nodes).toEqual(["login:0"]); + expect(tree.data["login:0"].inputs).toEqual({ + ls_error: _processingFailed("inputs", "processInputs"), + }); +}); + +test("SECURITY: traceable processOutputs failure drops the outputs (fail closed)", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((outputs: Readonly) => { + throw new Error(`redactor boom on ${JSON.stringify(outputs)}`); + }); + + const login = traceable( + async function login(_input: string) { + return SECRET; + }, + { client, tracingEnabled: true, processOutputs }, + ); + + const result = await login("user1"); + + expect(result).toBe(SECRET); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + // The mock body is a byte array, so assert against the decoded tree. + expect(JSON.stringify(tree)).not.toContain(SECRET); + expect(tree.nodes).toEqual(["login:0"]); + expect(tree.data["login:0"].outputs).toEqual({ + ls_error: _processingFailed("outputs", "processOutputs"), + }); + // Not errored: that status is for the traced fn failing, not post-processing. + expect(tree.data["login:0"].error).toBeFalsy(); +}); + +test("SECURITY: LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS restores raw inputs", async () => { + const { client, callSpy } = mockClient(); + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS = "true"; + try { + const processInputs = jest.fn((_inputs: Readonly) => { + throw new Error("redactor boom"); + }); + const login = traceable( + async function login(input: { username: string; password: string }) { + return `Welcome, ${input.username}`; + }, + { client, tracingEnabled: true, processInputs }, + ); + + expect(await login({ username: "user1", password: SECRET })).toBe( + "Welcome, user1", + ); + // Escape hatch: the raw payload is traced, no marker. + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.data["login:0"].inputs).toEqual({ + username: "user1", + password: SECRET, + }); + } finally { + // eslint-disable-next-line no-process-env + delete process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS; + } +}); + +test("SECURITY: LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS restores raw outputs", async () => { + const { client, callSpy } = mockClient(); + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS = "true"; + try { + const processOutputs = jest.fn((_outputs: Readonly) => { + throw new Error("redactor boom"); + }); + const login = traceable( + async function login(_input: string) { + return SECRET; + }, + { client, tracingEnabled: true, processOutputs }, + ); + + expect(await login("user1")).toBe(SECRET); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.data["login:0"].outputs).toEqual({ outputs: SECRET }); + } finally { + // eslint-disable-next-line no-process-env + delete process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS; + } +}); + +test("traceable async generator with processOutputs", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((outputs: Readonly) => { + return { outputs: outputs.outputs.map((output: number) => output * 2) }; + }); + + const func = traceable( + async function* func() { + for (let i = 1; i <= 3; i++) { + yield i; + } + }, + { + client, + tracingEnabled: true, + processOutputs, + }, + ); + + const results: number[] = []; + for await (const value of func()) { + results.push(value); + } + + expect(results).toEqual([1, 2, 3]); // Original values + expect(processOutputs).toHaveBeenCalledWith({ outputs: [1, 2, 3] }); + + // Tracing data should reflect the processed outputs + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + outputs: { outputs: [2, 4, 6] }, // Processed outputs + }, + }, + }); +}); + +test("traceable function returning object with async iterable and processOutputs", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((outputs: Readonly) => { + return { outputs: outputs.outputs.map((output: number) => output * 2) }; + }); + + const func = traceable( + async function func() { + return { + data: "some data", + stream: (async function* () { + for (let i = 1; i <= 3; i++) { + yield i; + } + })(), + }; + }, + { + client, + tracingEnabled: true, + processOutputs, + __finalTracedIteratorKey: "stream", + }, + ); + + const result = await func(); + expect(result.data).toBe("some data"); + + const results: number[] = []; + for await (const value of result.stream) { + results.push(value); + } + + expect(results).toEqual([1, 2, 3]); + expect(processOutputs).toHaveBeenCalledWith({ outputs: [1, 2, 3] }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + outputs: { outputs: [2, 4, 6] }, + }, + }, + }); +}); + +test("traceable generator function with processOutputs", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((outputs: Readonly) => { + return { outputs: outputs.outputs.map((output: number) => output * 2) }; + }); + + function* func() { + for (let i = 1; i <= 3; i++) { + yield i; + } + } + + const tracedFunc = traceable(func, { + client, + tracingEnabled: true, + processOutputs, + }); + + const results: number[] = []; + for (const value of await tracedFunc()) { + results.push(value); + } + + expect(results).toEqual([1, 2, 3]); + expect(processOutputs).toHaveBeenCalledWith({ outputs: [1, 2, 3] }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + outputs: { outputs: [2, 4, 6] }, + }, + }, + }); +}); + +test("traceable with complex outputs", async () => { + const { client, callSpy } = mockClient(); + + const processOutputs = jest.fn((outputs: Readonly) => { + return { data: "****", output: outputs.output, nested: outputs.nested }; + }); + + const func = traceable( + async function func(input: string) { + return { + data: "some sensitive data", + output: `Original Output for ${input}`, + nested: { + key: "value", + nestedOutput: `Nested Output for ${input}`, + }, + }; + }, + { + client, + tracingEnabled: true, + processOutputs, + }, + ); + + const result = await func("test"); + + expect(result).toEqual({ + data: "some sensitive data", + output: "Original Output for test", + nested: { + key: "value", + nestedOutput: "Nested Output for test", + }, + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + inputs: { input: "test" }, + outputs: { + data: "****", + output: "Original Output for test", + nested: { + key: "value", + nestedOutput: "Nested Output for test", + }, + }, + }, + }, + }); +}); + +test("traceable with usage metadata", async () => { + const { client, callSpy } = mockClient(); + + const func = traceable( + async function func(inputs: string) { + return { + messages: [ + { + role: "assistant", + content: inputs.split("").reverse().join(""), + }, + ], + usage_metadata: { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + }, + }; + }, + { + client, + tracingEnabled: true, + }, + ); + + const result = await func("foo"); + + expect(result).toEqual({ + messages: [ + { + role: "assistant", + content: "oof", + }, + ], + usage_metadata: { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + }, + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + extra: { + metadata: { + usage_metadata: { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + }, + }, + }, + inputs: { input: "foo" }, + outputs: { + messages: [ + { + role: "assistant", + content: "oof", + }, + ], + usage_metadata: { + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + }, + }, + }, + }, + }); +}); + +test("traceable with usage metadata with extract_usage", async () => { + const { client, callSpy } = mockClient(); + + const func = traceable( + async function func(inputs: string) { + const runTree = getCurrentRunTree(); + runTree.metadata.usage_metadata = { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }; + return { + messages: [ + { + role: "assistant", + content: inputs.split("").reverse().join(""), + }, + ], + }; + }, + { + client, + tracingEnabled: true, + }, + ); + + const result = await func("foo"); + + expect(result).toEqual({ + messages: [ + { + role: "assistant", + content: "oof", + }, + ], + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + extra: { + metadata: { + usage_metadata: { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }, + }, + }, + inputs: { input: "foo" }, + outputs: { + messages: [ + { + role: "assistant", + content: "oof", + }, + ], + usage_metadata: { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }, + }, + }, + }, + }); +}); + +test("traceable with usage metadata with streaming", async () => { + const { client, callSpy } = mockClient(); + + const func = traceable( + async function* func(inputs: string) { + for (const char of inputs) { + yield { + messages: [ + { + role: "assistant", + content: char, + }, + ], + }; + } + const runTree = getCurrentRunTree(); + runTree.metadata.usage_metadata = { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }; + }, + { + client, + tracingEnabled: true, + }, + ); + + const results: KVMap[] = []; + for await (const chunk of func("foo")) { + results.push(chunk); + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["func:0"], + edges: [], + data: { + "func:0": { + extra: { + metadata: { + usage_metadata: { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }, + }, + }, + inputs: { input: "foo" }, + outputs: { + usage_metadata: { + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + }, + }, + }, + }, + }); +}); + +test("serializes well-known types in inputs and outputs", async () => { + const { client, callSpy } = mockClient(); + + const func = traceable( + async (_input: { + map: Map; + set: Set; + date: Date; + regex: RegExp; + error: Error; + bigint: bigint; + }) => { + return { + processedMap: new Map([["result", "processed"]]), + processedSet: new Set([1, 2, 3]), + processedDate: new Date("2023-12-25T00:00:00.000Z"), + processedRegex: /processed.*pattern/gi, + processedError: new Error("Processed error"), + processedBigint: BigInt(987654321), + regularString: "normal output", + }; + }, + { client, tracingEnabled: true, name: "serializeTypesTest" }, + ); + + const inputMap = new Map([["input", "value"]]); + inputMap.set("nested", { deep: "object" }); + const inputSet = new Set(["a", "b", "c", 123]); + const inputDate = new Date("2023-01-01T00:00:00.000Z"); + const inputRegex = /input.*test/i; + const inputError = new Error("Input error message"); + inputError.name = "CustomError"; + const inputBigint = BigInt(123456789); + + const result = await func({ + map: inputMap, + set: inputSet, + date: inputDate, + regex: inputRegex, + error: inputError, + bigint: inputBigint, + }); + + // Verify the function returns the expected types + expect(result.processedMap).toBeInstanceOf(Map); + expect(result.processedSet).toBeInstanceOf(Set); + expect(result.processedDate).toBeInstanceOf(Date); + expect(result.processedRegex).toBeInstanceOf(RegExp); + expect(result.processedError).toBeInstanceOf(Error); + expect(typeof result.processedBigint).toBe("bigint"); + + // Verify serialization in traced inputs/outputs + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["serializeTypesTest:0"], + edges: [], + data: { + "serializeTypesTest:0": { + inputs: { + map: { input: "value", nested: { deep: "object" } }, // Map -> Object + set: ["a", "b", "c", 123], // Set -> Array + date: "2023-01-01T00:00:00.000Z", // Date -> ISO string + regex: "/input.*test/i", // RegExp -> string + error: { name: "CustomError", message: "Input error message" }, // Error -> safe object (no stack) + bigint: "123456789", // BigInt -> string + }, + outputs: { + processedMap: { result: "processed" }, // Map -> Object + processedSet: [1, 2, 3], // Set -> Array + processedDate: "2023-12-25T00:00:00.000Z", // Date -> ISO string + processedRegex: "/processed.*pattern/gi", // RegExp -> string + processedError: { name: "Error", message: "Processed error" }, // Error -> safe object + processedBigint: "987654321", // BigInt -> string + regularString: "normal output", // Regular string unchanged + }, + }, + }, + }); +}); + +// Unit tests converted from traceable.int.test.ts +test("traceable wrapper with error thrown", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + const addValueTraceable = traceable( + (_: string, __: number) => { + throw new Error("I am bad"); + }, + { + name: "add_value", + project_name: "__test_traceable_wrapper", + client: client, + id: runId, + tracingEnabled: true, + }, + ); + + expect(isTraceableFunction(addValueTraceable)).toBe(true); + + try { + await addValueTraceable("testing", 9); + expect(true).toBe(false); // Should not reach here + } catch (e: any) { + expect(e.message).toEqual("I am bad"); + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["add_value:0"]); + expect(tree.data["add_value:0"].error).toEqual("Error: I am bad"); +}); + +test("traceable wrapper with async error thrown", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + const addValueTraceable = traceable( + async (_: string, __: number) => { + throw new Error("I am bad"); + }, + { + name: "add_value", + project_name: "__test_traceable_wrapper", + client: client, + id: runId, + tracingEnabled: true, + }, + ); + + expect(isTraceableFunction(addValueTraceable)).toBe(true); + + try { + await addValueTraceable("testing", 9); + expect(true).toBe(false); // Should not reach here + } catch (e: any) { + expect(e.message).toEqual("I am bad"); + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["add_value:0"]); + expect(tree.data["add_value:0"].error).toEqual("Error: I am bad"); + expect(tree.data["add_value:0"].inputs).toEqual({ args: ["testing", 9] }); +}); + +test("traceable wrapper with nested calls", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + const addValueTraceable = traceable( + (a: string, b: number) => { + return a + b; + }, + { + name: "add_value", + project_name: "__test_traceable_wrapper", + client: client, + id: runId, + tracingEnabled: true, + }, + ); + + expect(await addValueTraceable("testing", 9)).toBe("testing9"); + expect(isTraceableFunction(addValueTraceable)).toBe(true); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["add_value:0"]); + expect(tree.data["add_value:0"].outputs).toEqual({ outputs: "testing9" }); + + // Test nested calls + const runId2 = uuidv4(); + const nestedAddValueTraceable = traceable( + (a: string, b: number) => { + return a + b; + }, + { + name: "nested_add_value", + project_name: "__test_traceable_wrapper", + client: client, + }, + ); + + const entryTraceable = traceable( + async (complex: { value: string }) => { + const result = await nestedAddValueTraceable(complex.value, 1); + const result2 = await nestedAddValueTraceable(result, 2); + return nestedAddValueTraceable(result2, 3); + }, + { + name: "run_with_nesting", + project_name: "__test_traceable_wrapper", + client: client, + id: runId2, + }, + ); + + expect(await entryTraceable({ value: "testing" })).toBe("testing123"); + expect(isTraceableFunction(entryTraceable)).toBe(true); +}); + +test("traceable wrapper with getCurrentRunTree", async () => { + const { client } = mockClient(); + + // Called outside a traceable function - should throw + expect(() => getCurrentRunTree()).toThrow(); + + const runId = uuidv4(); + const nestedAddValueTraceable = traceable( + (a: string, b: number) => { + const runTree = getCurrentRunTree(); + expect(runTree.id).toBeDefined(); + expect(runTree.id).not.toEqual(runId); + expect(runTree.dotted_order.includes(`${runId}.`)).toBe(true); + return a + b; + }, + { + name: "nested_add_value", + project_name: "__test_traceable_wrapper", + client: client, + }, + ); + + const addValueTraceable = traceable( + (a: string, b: number) => { + const runTree = getCurrentRunTree(); + expect(runTree.id).toBe(runId); + return nestedAddValueTraceable(a, b); + }, + { + name: "add_value", + project_name: "__test_traceable_wrapper", + client: client, + tracingEnabled: true, + id: runId, + }, + ); + + expect(await addValueTraceable("testing", 9)).toBe("testing9"); +}); + +test("traceable wrapper with aggregator function", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + let tracedOutput = ""; + + // Mock a streaming function that yields chunks + async function* mockStreamingFunction(input: string) { + const chunks = input.split(" "); + for (const chunk of chunks) { + yield { content: chunk }; + } + } + + const iterableTraceable = traceable(mockStreamingFunction, { + name: "streaming_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + tracedOutput = chunks.map((chunk) => chunk?.content ?? "").join(" "); + return tracedOutput; + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + const chunks: any[] = []; + for await (const chunk of iterableTraceable("Hello there")) { + chunks.push(chunk); + } + + expect(chunks.map((c) => c.content).join(" ")).toBe("Hello there"); + expect(tracedOutput).toBe("Hello there"); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["streaming_traceable:0"]); + expect(tree.data["streaming_traceable:0"].outputs).toEqual({ + outputs: tracedOutput, + }); +}); + +test("traceable async generator success", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + + const iterableTraceable = traceable(giveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + const results: any[] = []; + for await (const num of iterableTraceable()) { + results.push(num); + } + + expect(results).toEqual([0, 1, 2, 3, 4]); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0 1 2 3 4" }); +}); + +test("traceable async generator throws error", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + if (i == 2) { + throw new Error("I am bad"); + } + } + } + + const iterableTraceable = traceable(giveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + try { + for await (const _ of iterableTraceable()) { + // Pass + } + expect(true).toBe(false); // Should not reach here + } catch (err: any) { + expect(err.message).toEqual("I am bad"); + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0 1 2" }); + expect(tree.data["i_traceable:0"].error).toEqual("Error: I am bad"); +}); + +test("traceable async generator break finishes run", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + + const iterableTraceable = traceable(giveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + for await (const _ of iterableTraceable()) { + break; + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0" }); + expect(tree.data["i_traceable:0"].error).toEqual("Cancelled"); +}); + +test("traceable with function overloads", async () => { + const { client } = mockClient(); + + async function overload(a: string, b: number): Promise; + async function overload(config: { a: string; b: number }): Promise; + async function overload( + ...args: [a: string, b: number] | [config: { a: string; b: number }] + ): Promise { + if (args.length === 1) { + return args[0].a + args[0].b; + } + return args[0] + args[1]; + } + + const wrappedOverload = traceable(overload, { + name: "wrapped_overload", + project_name: "__test_traceable_wrapper", + client: client, + }); + + expect(await wrappedOverload("testing", 123)).toBe("testing123"); + expect(await wrappedOverload({ a: "testing", b: 456 })).toBe("testing456"); + expect(isTraceableFunction(wrappedOverload)).toBe(true); +}); + +test("traceable returning async generator", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + return giveMeNumbers(); + } + + const iterableTraceable = traceable(giveMeGiveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + for await (const _ of await iterableTraceable()) { + // Pass + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0 1 2 3 4" }); +}); + +test("traceable promise for async generator with error", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + if (i == 2) { + throw new Error("I am bad"); + } + } + } + return giveMeNumbers(); + } + + const iterableTraceable = traceable(giveMeGiveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + try { + for await (const _ of await iterableTraceable()) { + // Pass + } + expect(true).toBe(false); // Should not reach here + } catch (err: any) { + expect(err.message).toEqual("I am bad"); + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0 1 2" }); + expect(tree.data["i_traceable:0"].error).toEqual("Error: I am bad"); +}); + +test("traceable promise for async generator break", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + async function giveMeGiveMeNumbers() { + async function* giveMeNumbers() { + for (let i = 0; i < 5; i++) { + yield i; + } + } + return giveMeNumbers(); + } + + const iterableTraceable = traceable(giveMeGiveMeNumbers, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + aggregator: (chunks) => { + return chunks.join(" "); + }, + tracingEnabled: true, + }); + + expect(isTraceableFunction(iterableTraceable)).toBe(true); + + for await (const _ of await iterableTraceable()) { + break; + } + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: "0" }); + expect(tree.data["i_traceable:0"].error).toEqual("Cancelled"); +}); + +test("passing null doesn't throw an error", async () => { + const { client, callSpy } = mockClient(); + const runId = uuidv4(); + + const func = traceable(async (input: null) => input, { + name: "i_traceable", + project_name: "__test_traceable_wrapper_aggregator", + client: client, + id: runId, + tracingEnabled: true, + }); + + expect(await func(null)).toBe(null); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["i_traceable:0"]); + expect(tree.data["i_traceable:0"].inputs).toEqual({ inputs: null }); + expect(tree.data["i_traceable:0"].outputs).toEqual({ outputs: null }); +}); + +test("traceable with invalid properties in usage metadata", async () => { + const { client, callSpy } = mockClient(); + const traceableLLM = traceable( + (_input: Record) => { + return { + messages: [ + { + role: "assistant", + content: "Hello, world!", + }, + ], + usage_metadata: { + foo: "bar", + input_tokens: 10, + output_tokens: 10, + total_tokens: 20, + }, + }; + }, + { + name: "extra_usage_metadata_run", + metadata: { + ls_provider: "anthropic", + ls_model_name: "claude-sonnet-4-20250514", + }, + client, + run_type: "llm", + tracingEnabled: true, + }, + ); + + await traceableLLM({}); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["extra_usage_metadata_run:0"]); + + expect( + tree.data["extra_usage_metadata_run:0"].extra?.metadata?.usage_metadata, + ).toEqual({ + foo: "bar", + input_tokens: 10, + output_tokens: 10, + total_tokens: 20, + }); +}); + +test("traceable should ignore undefined id", async () => { + const { client, callSpy } = mockClient(); + const traceableLLM = traceable( + (_input: Record) => { + return { + messages: [ + { + role: "assistant", + content: "Hello, world!", + }, + ], + usage_metadata: { + foo: "bar", + input_tokens: 10, + output_tokens: 10, + total_tokens: 20, + }, + }; + }, + { + // simulate bad spread + id: undefined, + dotted_order: undefined, + trace_id: undefined, + name: "extra_usage_metadata_run", + start_time: Date.now(), + metadata: { + ls_provider: "anthropic", + ls_model_name: "claude-sonnet-4-20250514", + }, + client, + run_type: "llm", + tracingEnabled: true, + }, + ); + + await traceableLLM({}); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls).toHaveLength(2); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["extra_usage_metadata_run:0"]); + expect(tree.data["extra_usage_metadata_run:0"].id).toBeDefined(); + expect(tree.data["extra_usage_metadata_run:0"].dotted_order).toBeDefined(); + expect(tree.data["extra_usage_metadata_run:0"].trace_id).toBeDefined(); + expect(tree.data["extra_usage_metadata_run:0"].id).toEqual( + tree.data["extra_usage_metadata_run:0"].trace_id, + ); + expect( + tree.data["extra_usage_metadata_run:0"].dotted_order?.includes( + tree.data["extra_usage_metadata_run:0"].id, + ), + ).toBe(true); +}); + +test("traceable with nested calls and reroot replicas", async () => { + const { client, callSpy } = mockClient({ + callerOptions: { maxRetries: 0 }, + timeout_ms: 30_000, + }); + + // Define nested traceable functions - innermost ones don't need replicas + const innerTask = traceable( + async (input: string) => { + return `processed: ${input}`; + }, + { name: "innerTask", client, tracingEnabled: true }, + ); + + const middleTask = traceable( + async (input: string) => { + const result1 = await innerTask(`${input}-a`); + const result2 = await innerTask(`${input}-b`); + return `${result1}, ${result2}`; + }, + { name: "middleTask", client, tracingEnabled: true }, + ); + + // Outer task has replicas configured directly + const outerTask = traceable( + async (input: string) => { + const result = await middleTask(input); + return `final: ${result}`; + }, + { + name: "outerTask", + client, + tracingEnabled: true, + replicas: [ + { + projectName: "child-workspace-rerooted", + apiKey: "child-key", + apiUrl: "https://child.example.com", + reroot: true, + }, + { + projectName: "full-trace-workspace", + apiKey: "full-key", + apiUrl: "https://full.example.com", + reroot: false, + }, + ], + }, + ); + + // Execute the nested traceable calls + const result = await outerTask("test-input"); + + // Verify the result + expect(result).toBe( + "final: processed: test-input-a, processed: test-input-b", + ); + + // Wait for async operations + await client.awaitPendingTraceBatches(); + + // Verify API calls were made to both replicas + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + // Parse the POST bodies to verify rerooting behavior + const childPostCalls = callSpy.mock.calls.filter( + (call: any) => + (call[0] as string).includes("child.example.com") && + (call[0] as string).includes("/runs") && + (call[1] as any)?.method === "POST", + ); + const fullPostCalls = callSpy.mock.calls.filter( + (call: any) => + (call[0] as string).includes("full.example.com") && + (call[0] as string).includes("/runs") && + (call[1] as any)?.method === "POST", + ); + + expect(childPostCalls.length).toBeGreaterThan(0); + expect(fullPostCalls.length).toBeGreaterThan(0); + + // Verify correct API keys + childPostCalls.forEach((call: any) => { + const headers = (call[1] as any)?.headers; + expect(headers["x-api-key"]).toBe("child-key"); + }); + fullPostCalls.forEach((call: any) => { + const headers = (call[1] as any)?.headers; + expect(headers["x-api-key"]).toBe("full-key"); + }); + + // Use the tree utility to parse runs from mock calls + const childTree = await getAssumedTreeFromCalls(childPostCalls, client); + const fullTree = await getAssumedTreeFromCalls(fullPostCalls, client); + + // Find outerTask and middleTask in both replicas to verify rerooting behavior + const childOuterTask = Object.values(childTree.data).find( + (run) => run.name === "outerTask", + ); + const fullOuterTask = Object.values(fullTree.data).find( + (run) => run.name === "outerTask", + ); + const childMiddleTask = Object.values(childTree.data).find( + (run) => run.name === "middleTask", + ); + const fullMiddleTask = Object.values(fullTree.data).find( + (run) => run.name === "middleTask", + ); + + expect(childOuterTask).toBeDefined(); + expect(fullOuterTask).toBeDefined(); + expect(childMiddleTask).toBeDefined(); + expect(fullMiddleTask).toBeDefined(); + + // Type assertions after checking they're defined + if ( + !childOuterTask || + !fullOuterTask || + !childMiddleTask || + !fullMiddleTask + ) { + throw new Error("Expected runs to be defined"); + } + + // With reroot=true on outerTask, outerTask should NOT have a parent_run_id in child workspace + // (it becomes the root of a new tree) + expect(childOuterTask.parent_run_id).toBeUndefined(); + + // middleTask should STILL have a parent_run_id in the rerooted workspace + // (reroot doesn't propagate to children - we want a new tree, not all root runs) + expect(childMiddleTask.parent_run_id).toBeDefined(); + + // In the full workspace without reroot, both maintain their normal parent relationships + expect(fullMiddleTask.parent_run_id).toBeDefined(); + + // Verify tree structure is maintained in rerooted workspace + // Should have 4 runs total: outerTask, middleTask, innerTask x2 + expect(Object.keys(childTree.data).length).toBe(4); + expect(Object.keys(fullTree.data).length).toBe(4); + + // Verify the tree edges in rerooted workspace form a proper tree + // outerTask (root) -> middleTask -> innerTask (x2) + expect(childTree.edges.length).toBe(3); // 3 parent-child relationships + + // Verify trace_id is updated for rerooted runs + // In rerooted workspace, all runs should have trace_id pointing to outerTask + expect(childOuterTask.trace_id).toBe(childOuterTask.id); + expect(childMiddleTask.trace_id).toBe(childOuterTask.id); + + // Verify dotted_order is properly reset for the root run + // Rerooted run should have only one segment in dotted_order + const childOuterSegments = childOuterTask.dotted_order?.split(".") || []; + expect(childOuterSegments.length).toBe(1); +}, 180_000); + +test("child traceable with own replicas config", async () => { + const { client, callSpy } = mockClient(); + + const defaultProject = "mainProject"; + const replicaProject = "subrun"; + + const greatGrandchild = traceable( + async () => { + return "greatGrandchild"; + }, + { + name: "greatGrandchild", + client, + tracingEnabled: true, + }, + ); + + const grandchild = traceable( + async () => { + const greatGrandchildRes = await greatGrandchild(); + return "grandchild: " + greatGrandchildRes; + }, + { + name: "grandchild", + client, + tracingEnabled: true, + }, + ); + const child = traceable( + async () => { + const grandchildRes = await grandchild(); + return "child: " + grandchildRes; + }, + { + replicas: [ + { + projectName: defaultProject, + }, + { + projectName: replicaProject, + reroot: true, + }, + ], + name: "child", + client, + tracingEnabled: true, + }, + ); + const parent = traceable( + async () => { + const childRes = await child(); + return "parent: " + childRes; + }, + { + name: "parent", + client, + tracingEnabled: true, + project_name: defaultProject, + }, + ); + + await parent(); + + // Wait for async operations + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Verify that child's replicas were used by checking calls to both projects + const allPostCalls = callSpy.mock.calls.filter( + (call: any) => + (call[0] as string).includes("/runs") && + (call[1] as any)?.method === "POST", + ); + + // Parent should only go to default project (no replicas) + // Child and grandchild should go to both "main-project" and replicaProject + // greatGrandchild should also go to both (inherits from grandchild which inherits from child) + // Total: 1 (parent) + 2 (child) + 2 (grandchild) + 2 (greatGrandchild) = 7 POST calls + expect(allPostCalls.length).toBe(7); + + // Parse calls - since all go to same mock endpoint, we need to check session_name in body + const runs = allPostCalls.map((call: any) => { + const body = (call[1] as any)?.body; + let bodyStr: string; + if (typeof body === "string") { + bodyStr = body; + } else if (Buffer.isBuffer(body)) { + bodyStr = body.toString("utf-8"); + } else if (ArrayBuffer.isView(body)) { + bodyStr = new TextDecoder().decode(body); + } else { + bodyStr = JSON.stringify(body); + } + return JSON.parse(bodyStr); + }); + + // Count runs by project + const defaultProjectRuns = runs.filter( + (r: any) => r.session_name === defaultProject, + ); + const subrunRuns = runs.filter((r: any) => r.session_name === replicaProject); + + // We should have: 1 parent, 1 child, 1 grandchild, 1 greatGrandchild in default + // Plus: 1 child, 1 grandchild, 1 greatGrandchild in subrun (child's replicas inherited) + // Total: 7 runs (4 in default + 3 in subrun) + expect(runs.length).toBe(7); + + // Parent only goes to default project (no replicas) + const parentRuns = runs.filter((r: any) => r.name === "parent"); + expect(parentRuns.length).toBe(1); + expect(parentRuns[0].session_name).toBe(defaultProject); + + // Child goes to BOTH default project and subrun (child's replicas) + const childRuns = runs.filter((r: any) => r.name === "child"); + expect(childRuns.length).toBe(2); + const childProjects = childRuns.map((r: any) => r.session_name).sort(); + expect(childProjects).toEqual([defaultProject, replicaProject].sort()); + + // Grandchild also goes to BOTH (inherits child's replicas) + const grandchildRuns = runs.filter((r: any) => r.name === "grandchild"); + expect(grandchildRuns.length).toBe(2); + const grandchildProjects = grandchildRuns + .map((r: any) => r.session_name) + .sort(); + expect(grandchildProjects).toEqual([defaultProject, replicaProject].sort()); + + // GreatGrandchild also goes to BOTH (inherits from grandchild which inherits from child) + const greatGrandchildRuns = runs.filter( + (r: any) => r.name === "greatGrandchild", + ); + expect(greatGrandchildRuns.length).toBe(2); + const greatGrandchildProjects = greatGrandchildRuns + .map((r: any) => r.session_name) + .sort(); + expect(greatGrandchildProjects).toEqual( + [defaultProject, replicaProject].sort(), + ); + + // Verify reroot behavior in replicaProject project + const subrunChild = subrunRuns.find((r: any) => r.name === "child"); + const subrunGrandchild = subrunRuns.find((r: any) => r.name === "grandchild"); + const subrunGreatGrandchild = subrunRuns.find( + (r: any) => r.name === "greatGrandchild", + ); + + if (!subrunChild || !subrunGrandchild || !subrunGreatGrandchild) { + throw new Error("Expected subrun runs to be defined"); + } + + // Child should be a root in subrun (reroot: true) + expect(subrunChild.parent_run_id).toBeUndefined(); + + // Child's dotted_order should have only one segment (it's a new root) + const childDottedSegments = subrunChild.dotted_order?.split(".") || []; + expect(childDottedSegments.length).toBe(1); + + // Child's trace_id should be its own id (new trace root after reroot) + expect(subrunChild.trace_id).toBe(subrunChild.id); + + // Grandchild should have child as parent (inherited replicas, reroot doesn't propagate) + expect(subrunGrandchild.parent_run_id).toBeDefined(); + + // Grandchild's trace_id should match child's id (same trace in rerooted tree) + // This is the key fix - with ID remapping, grandchild gets the remapped child's id as trace_id + expect(subrunGrandchild.trace_id).toBe(subrunChild.id); + + // Verify parent_run_id is the remapped child id + expect(subrunGrandchild.parent_run_id).toBe(subrunChild.id); + + // Grandchild's dotted_order should have two segments (child.grandchild) + const grandchildDottedSegments = + subrunGrandchild.dotted_order?.split(".") || []; + expect(grandchildDottedSegments.length).toBe(2); + + // GreatGrandchild should also be part of the rerooted trace + expect(subrunGreatGrandchild.trace_id).toBe(subrunChild.id); + expect(subrunGreatGrandchild.parent_run_id).toBe(subrunGrandchild.id); + + // GreatGrandchild's dotted_order should have three segments (child.grandchild.greatGrandchild) + const greatGrandchildDottedSegments = + subrunGreatGrandchild.dotted_order?.split(".") || []; + expect(greatGrandchildDottedSegments.length).toBe(3); + + // In the default project (no reroot), verify child still has parent + const defaultChild = defaultProjectRuns.find((r: any) => r.name === "child"); + const defaultGrandchild = defaultProjectRuns.find( + (r: any) => r.name === "grandchild", + ); + const defaultGreatGrandchild = defaultProjectRuns.find( + (r: any) => r.name === "greatGrandchild", + ); + + if (!defaultChild || !defaultGrandchild || !defaultGreatGrandchild) { + throw new Error("Expected default project runs to be defined"); + } + + // In default project, child should have parent (no reroot there) + expect(defaultChild.parent_run_id).toBeDefined(); + + // All runs in default project should share same trace_id (parent's trace) + const defaultParent = defaultProjectRuns.find( + (r: any) => r.name === "parent", + ); + expect(defaultChild.trace_id).toBe(defaultParent?.trace_id); + expect(defaultGrandchild.trace_id).toBe(defaultParent?.trace_id); + expect(defaultGreatGrandchild.trace_id).toBe(defaultParent?.trace_id); +}); + +test("type test: AsyncIterable return type not wrapped in Promise", async () => { + const { client } = mockClient(); + // Test that async iterable functions return AsyncIterable, not Promise + async function* testAsyncIterable(): AsyncIterable { + yield "test"; + } + + const wrapped = traceable(testAsyncIterable, { client }); + + // This should work without await - the return type should be AsyncIterable + // not Promise> + const result = wrapped(); + + // If the type is correct, we can iterate directly + for await (const value of result) { + expect(typeof value).toBe("string"); + } + + // Type assertion to verify compile-time type + const _typeCheck: AsyncIterable = wrapped(); + for await (const _ of _typeCheck) { + // pass + } +}); + +test("type test: RunTree overload works correctly", async () => { + const { client } = mockClient(); + // Test that RunTree can be passed as first argument + const testFunc = async (input: string) => `output: ${input}`; + const wrapped = traceable(testFunc, { + client, + }); + + // Should work with RunTree as first arg + const runTree = new RunTree({ name: "test" }); + await wrapped(runTree, "test"); + + // Should work with ROOT as first arg + await wrapped(ROOT, "test"); + + // Should work without RunTree + const result = await wrapped("test"); + expect(result).toBe("output: test"); + + // @ts-expect-error Typing should not permit random objects + await wrapped({ foo: "bar" }, "foo"); + + // @ts-expect-error Typing should not permit callbacks that are not objects + await wrapped({ callbacks: "bar" }, "foo"); + + // Should allow an object, array, or the special ROOT value + await wrapped({ callbacks: [] }, "foo"); + await wrapped({ callbacks: {} }, "foo"); + + // @ts-expect-error Typing should not permit additional args + await wrapped("ROOT", "foo"); +}); + +describe("tracingEnabled: false propagation to nested traceables", () => { + test("nested traceable traces normally when parent has tracingEnabled: true", async () => { + const { client, callSpy } = mockClient(); + + const child = traceable( + async function child(input: string) { + return `child: ${input}`; + }, + { client, tracingEnabled: true }, + ); + + const parent = traceable( + async function parent(input: string) { + return child(input); + }, + { client, tracingEnabled: true }, + ); + + const result = await parent("hello"); + expect(result).toBe("child: hello"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["parent:0", "child:1"], + edges: [["parent:0", "child:1"]], + }); + }); + + test("nested traceable without explicit tracingEnabled inherits false from parent", async () => { + const { client } = mockClient(); + + let childStoreValue: unknown; + + const child = traceable( + async function child(input: string) { + // Inspect the ALS store inside the child's execution context + // to verify the parent's tracingEnabled: false was propagated + childStoreValue = + AsyncLocalStorageProviderSingleton.getInstance().getStore(); + return `child: ${input}`; + }, + // no explicit tracingEnabled — should inherit false from parent + { client }, + ); + + const parent = traceable( + async function parent(input: string) { + return child(input); + }, + { tracingEnabled: false }, + ); + + const result = await parent("hello"); + expect(result).toBe("child: hello"); + + // The child's store should be a ContextPlaceholder (not a RunTree) + // with tracingEnabled: false propagated from the parent + expect(childStoreValue).toBeDefined(); + expect(childStoreValue).not.toBeInstanceOf(RunTree); + expect((childStoreValue as Record).tracingEnabled).toBe( + false, + ); + }); +}); + +test("LS_MESSAGE_VIEW_EXCLUDE constant is exported with the correct value", () => { + expect(LS_MESSAGE_VIEW_EXCLUDE).toBe("ls_message_view_exclude"); +}); + +test("LS_MESSAGE_VIEW_EXCLUDE metadata round-trips to run via traceable", async () => { + const { client, callSpy } = mockClient(); + const classify = traceable(async (x: string): Promise => x, { + client, + name: "classify", + metadata: { [LS_MESSAGE_VIEW_EXCLUDE]: true }, + tracingEnabled: true, + }); + + await classify("hello"); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["classify:0"], + edges: [], + data: { + "classify:0": { + extra: { metadata: { [LS_MESSAGE_VIEW_EXCLUDE]: true } }, + }, + }, + }); +}); + +test("LS_MESSAGE_VIEW_EXCLUDE metadata cascades from parent to child runs", async () => { + const { client, callSpy } = mockClient(); + const child = traceable(async (): Promise => "ok", { name: "child" }); + const parent = traceable(async (): Promise => child(), { + client, + name: "parent", + metadata: { [LS_MESSAGE_VIEW_EXCLUDE]: true }, + tracingEnabled: true, + }); + + await parent(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const runs = Object.values(tree.data); + expect(runs.length).toBeGreaterThanOrEqual(2); + for (const run of runs) { + const md = ((run.extra as any) ?? {}).metadata ?? {}; + expect(md[LS_MESSAGE_VIEW_EXCLUDE]).toBe(true); + } +}); diff --git a/src/lib/tests/traceable_langchain.test.ts b/src/lib/tests/traceable_langchain.test.ts new file mode 100644 index 0000000..c926307 --- /dev/null +++ b/src/lib/tests/traceable_langchain.test.ts @@ -0,0 +1,624 @@ +/* eslint-disable no-process-env */ +import { traceable } from "../traceable.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { mockClient } from "./utils/mock_client.js"; +import { FakeChatModel } from "@langchain/core/utils/testing"; +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { StringOutputParser } from "@langchain/core/output_parsers"; +import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain"; +import { BaseMessage, HumanMessage } from "@langchain/core/messages"; +import { awaitAllCallbacks } from "@langchain/core/callbacks/promises"; +import { RunnableTraceable, getLangchainCallbacks } from "../langchain.js"; +import { RunnableLambda } from "@langchain/core/runnables"; +import { + setContextVariable, + getContextVariable, +} from "@langchain/core/context"; + +describe("to langchain", () => { + const llm = new FakeChatModel({}); + const prompt = ChatPromptTemplate.fromMessages<{ text: string }>([ + ["human", "{text}"], + ]); + const parser = new StringOutputParser(); + const chain = prompt.pipe(llm).pipe(parser); + + test("invoke", async () => { + const { client, callSpy } = mockClient(); + + const main = traceable( + async (input: { text: string }) => { + return chain.invoke(input, { + callbacks: await getLangchainCallbacks(), + }); + }, + { + name: "main", + client, + tracingEnabled: true, + tags: ["welcome"], + metadata: { hello: "world" }, + }, + ); + + const result = await main({ text: "Hello world" }); + expect(result).toEqual("Hello world"); + + await awaitAllCallbacks(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "main:0", + "RunnableSequence:1", + "ChatPromptTemplate:2", + "FakeChatModel:3", + "StrOutputParser:4", + ], + edges: [ + ["main:0", "RunnableSequence:1"], + ["RunnableSequence:1", "ChatPromptTemplate:2"], + ["RunnableSequence:1", "FakeChatModel:3"], + ["RunnableSequence:1", "StrOutputParser:4"], + ], + data: { + "main:0": { + inputs: { text: "Hello world" }, + outputs: { outputs: "Hello world" }, + tags: ["welcome"], + extra: { metadata: { hello: "world" } }, + }, + }, + }); + }); + + test("stream", async () => { + const { client, callSpy } = mockClient(); + + const main = traceable( + async function* main(input: { text: string }) { + for await (const token of await chain.stream(input, { + callbacks: await getLangchainCallbacks(), + })) { + yield token; + } + }, + { client, tracingEnabled: true }, + ); + + const result = []; + for await (const token of main({ text: "Hello world" })) { + result.push(token); + } + + expect(result).toEqual(["Hello world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "main:0", + "RunnableSequence:1", + "ChatPromptTemplate:2", + "FakeChatModel:3", + "StrOutputParser:4", + ], + edges: [ + ["main:0", "RunnableSequence:1"], + ["RunnableSequence:1", "ChatPromptTemplate:2"], + ["RunnableSequence:1", "FakeChatModel:3"], + ["RunnableSequence:1", "StrOutputParser:4"], + ], + }); + }); + + test("batch", async () => { + const { client, callSpy } = mockClient(); + + const main = traceable( + async (input: { texts: string[] }) => { + return chain.batch( + input.texts.map((text) => ({ text })), + { callbacks: await getLangchainCallbacks() }, + ); + }, + { name: "main", client, tracingEnabled: true }, + ); + + const result = await main({ texts: ["Hello world", "Who are you?"] }); + + await awaitAllCallbacks(); + expect(result).toEqual(["Hello world", "Who are you?"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "main:0", + "RunnableSequence:1", + "RunnableSequence:2", + "ChatPromptTemplate:3", + "ChatPromptTemplate:4", + "FakeChatModel:5", + "FakeChatModel:6", + "StrOutputParser:7", + "StrOutputParser:8", + ], + edges: [ + ["main:0", "RunnableSequence:1"], + ["main:0", "RunnableSequence:2"], + ["RunnableSequence:1", "ChatPromptTemplate:3"], + ["RunnableSequence:2", "ChatPromptTemplate:4"], + ["RunnableSequence:1", "FakeChatModel:5"], + ["RunnableSequence:2", "FakeChatModel:6"], + ["RunnableSequence:1", "StrOutputParser:7"], + ["RunnableSequence:2", "StrOutputParser:8"], + ], + }); + }); +}); + +describe("to traceable", () => { + test("invoke", async () => { + const { client, callSpy } = mockClient(); + + const llm = new FakeChatModel({}); + const prompt = ChatPromptTemplate.fromMessages<{ text: string }>([ + ["human", "{text}"], + ]); + const parser = new StringOutputParser(); + + const addValueTraceable = traceable( + (msg: BaseMessage) => + new HumanMessage({ content: msg.content + " world" }), + { name: "add_negligible_value" }, + ); + + const chain = prompt + .pipe(llm) + .pipe(RunnableTraceable.from(addValueTraceable)) + .pipe(parser); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore client might be of different type + const tracer = new LangChainTracer({ client }); + const response = await chain.invoke( + { text: "Hello" }, + { callbacks: [tracer] }, + ); + + // callbacks are backgrounded by default + await awaitAllCallbacks(); + + expect(response).toEqual("Hello world"); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "RunnableSequence:0", + "ChatPromptTemplate:1", + "FakeChatModel:2", + "add_negligible_value:3", + "StrOutputParser:4", + ], + edges: [ + ["RunnableSequence:0", "ChatPromptTemplate:1"], + ["RunnableSequence:0", "FakeChatModel:2"], + ["RunnableSequence:0", "add_negligible_value:3"], + ["RunnableSequence:0", "StrOutputParser:4"], + ], + }); + }); + + test("array stream", async () => { + const { client, callSpy } = mockClient(); + + const source = RunnableTraceable.from( + traceable(function (input: { text: string }) { + return input.text.split(" "); + }), + ); + + const tokens: unknown[] = []; + for await (const chunk of await source.stream( + { text: "Hello world" }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore client might be of different type + { callbacks: [new LangChainTracer({ client })] }, + )) { + tokens.push(chunk); + } + + expect(tokens).toEqual([["Hello", "world"]]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [":0"], + edges: [], + }); + }); + + test("generator stream", async () => { + const { client, callSpy } = mockClient(); + + const source = RunnableTraceable.from( + traceable(function* (input: { text: string }) { + const chunks = input.text.split(" "); + for (const chunk of chunks) { + yield chunk; + } + }), + ); + + const tokens: unknown[] = []; + for await (const chunk of await source.stream( + { text: "Hello world" }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore client might be of different type + { callbacks: [new LangChainTracer({ client })] }, + )) { + tokens.push(chunk); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [":0"], + edges: [], + }); + }); + + test("readable stream", async () => { + const { client, callSpy } = mockClient(); + + const source = RunnableTraceable.from( + traceable(async function (input: { text: string }) { + const readStream = new ReadableStream({ + async pull(controller) { + for (const item of input.text.split(" ")) { + controller.enqueue(item); + } + controller.close(); + }, + }); + + return readStream; + }), + ); + + const tokens: unknown[] = []; + for await (const chunk of await source.stream( + { text: "Hello world" }, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore client might be of different type + { callbacks: [new LangChainTracer({ client })] }, + )) { + tokens.push(chunk); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [":0"], + edges: [], + }); + }); + + test("async generator stream", async () => { + const { client, callSpy } = mockClient(); + const source = RunnableTraceable.from( + traceable(async function* (input: { text: string }) { + const chunks = input.text.split(" "); + for (const chunk of chunks) { + yield chunk; + } + }), + ); + + const tokens: unknown[] = []; + for await (const chunk of await source.stream( + { text: "Hello world" }, + { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore client might be of different type + callbacks: [new LangChainTracer({ client })], + }, + )) { + tokens.push(chunk); + } + + expect(tokens).toEqual(["Hello", "world"]); + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [":0"], + edges: [], + }); + }); +}); + +test("explicit nested", async () => { + const { client, callSpy } = mockClient(); + + const llm = new FakeChatModel({}); + const prompt = ChatPromptTemplate.fromMessages<{ text: string }>([ + ["human", "{text}"], + ]); + const parser = new StringOutputParser(); + const chain = prompt.pipe(llm).pipe(parser).withConfig({ runName: "chain" }); + + const wrappedModel = new RunnableTraceable({ + func: traceable( + async (value: { input: string }) => { + const callbacks = await getLangchainCallbacks(); + + return chain.invoke( + { text: `Wrapped input: ${value.input}` }, + { callbacks }, + ); + }, + { name: "wrappedModel" }, + ), + }); + + const main = traceable( + async () => { + return { + response: [ + await wrappedModel.invoke( + { input: "Are you ready?" }, + { callbacks: await getLangchainCallbacks() }, + ), + await wrappedModel.invoke( + { input: "I said, Are. You. Ready?" }, + { callbacks: await getLangchainCallbacks() }, + ), + ], + }; + }, + { name: "main", client, tracingEnabled: true }, + ); + + const result = await main(); + await awaitAllCallbacks(); + + expect(result).toEqual({ + response: [ + "Wrapped input: Are you ready?", + "Wrapped input: I said, Are. You. Ready?", + ], + }); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "main:0", + "wrappedModel:1", + "chain:2", + "ChatPromptTemplate:3", + "FakeChatModel:4", + "StrOutputParser:5", + "wrappedModel:6", + "chain:7", + "ChatPromptTemplate:8", + "FakeChatModel:9", + "StrOutputParser:10", + ], + edges: [ + ["main:0", "wrappedModel:1"], + ["wrappedModel:1", "chain:2"], + ["chain:2", "ChatPromptTemplate:3"], + ["chain:2", "FakeChatModel:4"], + ["chain:2", "StrOutputParser:5"], + ["main:0", "wrappedModel:6"], + ["wrappedModel:6", "chain:7"], + ["chain:7", "ChatPromptTemplate:8"], + ["chain:7", "FakeChatModel:9"], + ["chain:7", "StrOutputParser:10"], + ], + }); +}); + +describe("LangChain context variables", () => { + test.each(["true", "false"])( + "set and get context variables at top level with tracingEnabled=%s", + async (tracingEnabled) => { + process.env.LANGSMITH_TRACING = tracingEnabled; + const { client } = mockClient(); + setContextVariable("foo", "bar"); + expect(getContextVariable("foo")).toEqual("bar"); + + const main = traceable( + async () => { + expect(getContextVariable("foo")).toEqual("bar"); + return "Something"; + }, + { + client, + }, + ); + await main(); + await awaitAllCallbacks(); + }, + ); + + test.each(["true", "false"])( + "set and get context variables from runnable nested in traceable with tracingEnabled=%s", + async (tracingEnabled) => { + process.env.LANGSMITH_TRACING = tracingEnabled; + const { client } = mockClient(); + + const nested = RunnableLambda.from(async () => { + expect(getContextVariable("foo")).toEqual("baz"); + + return "Something"; + }); + + const main = traceable( + async () => { + setContextVariable("foo", "baz"); + expect(getContextVariable("foo")).toEqual("baz"); + return nested.invoke({}); + }, + { + client, + }, + ); + await main(); + await awaitAllCallbacks(); + }, + ); + + test.each(["true", "false"])( + "set and get context variables from traceable nested in runnable with tracingEnabled=%s", + async (tracingEnabled) => { + process.env.LANGSMITH_TRACING = tracingEnabled; + const { client } = mockClient(); + + const nested = traceable( + async () => { + expect(getContextVariable("foo")).toEqual("qux"); + return "Something"; + }, + { + client, + }, + ); + + const main = RunnableLambda.from(async () => { + setContextVariable("foo", "qux"); + expect(getContextVariable("foo")).toEqual("qux"); + return nested(); + }); + await main.invoke({}); + await awaitAllCallbacks(); + }, + ); +}); + +// skip until the @langchain/core 0.2.17 is out +describe.skip("automatic tracing", () => { + it("root langchain", async () => { + const { callSpy, langChainTracer, client } = mockClient(); + + const lc = RunnableLambda.from(async () => "Hello from LangChain"); + const ls = traceable(() => "Hello from LangSmith", { name: "traceable" }); + + const childA = RunnableLambda.from(async () => { + const results: string[] = []; + results.push(await lc.invoke({})); + results.push(await ls()); + return results.join("\n"); + }); + + const childB = traceable( + async () => [await lc.invoke({}), await ls()].join("\n"), + { name: "childB" }, + ); + + const rootLC = RunnableLambda.from(async () => { + return [ + await childA.invoke({}, { runName: "childA" }), + await childB(), + ].join("\n"); + }); + + expect( + await rootLC.invoke( + {}, + { callbacks: [langChainTracer], runName: "rootLC" }, + ), + ).toEqual( + [ + "Hello from LangChain", + "Hello from LangSmith", + "Hello from LangChain", + "Hello from LangSmith", + ].join("\n"), + ); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "rootLC:0", + "childA:1", + "RunnableLambda:2", + "traceable:3", + "childB:4", + "RunnableLambda:5", + "traceable:6", + ], + edges: [ + ["rootLC:0", "childA:1"], + ["childA:1", "RunnableLambda:2"], + ["childA:1", "traceable:3"], + ["rootLC:0", "childB:4"], + ["childB:4", "RunnableLambda:5"], + ["childB:4", "traceable:6"], + ], + }); + }); + + it("root traceable", async () => { + const { client, callSpy } = mockClient(); + + const lc = RunnableLambda.from(async () => "Hello from LangChain"); + const ls = traceable(() => "Hello from LangSmith", { name: "traceable" }); + + const childA = RunnableLambda.from(async () => { + const results: string[] = []; + results.push(await lc.invoke({})); + results.push(await ls()); + return results.join("\n"); + }); + + const childB = traceable( + async () => [await lc.invoke({}), await ls()].join("\n"), + { name: "childB" }, + ); + + const rootLS = traceable( + async () => { + return [ + await childA.invoke({}, { runName: "childA" }), + await childB(), + ].join("\n"); + }, + { name: "rootLS", client, tracingEnabled: true }, + ); + + expect(await rootLS()).toEqual( + [ + "Hello from LangChain", + "Hello from LangSmith", + "Hello from LangChain", + "Hello from LangSmith", + ].join("\n"), + ); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: [ + "rootLS:0", + "childA:1", + "RunnableLambda:2", + "traceable:3", + "childB:4", + "RunnableLambda:5", + "traceable:6", + ], + edges: [ + ["rootLS:0", "childA:1"], + ["childA:1", "RunnableLambda:2"], + ["childA:1", "traceable:3"], + ["rootLS:0", "childB:4"], + ["childB:4", "RunnableLambda:5"], + ["childB:4", "traceable:6"], + ], + }); + }); +}); diff --git a/src/lib/tests/traces.int.test.ts b/src/lib/tests/traces.int.test.ts new file mode 100644 index 0000000..95fe2bc --- /dev/null +++ b/src/lib/tests/traces.int.test.ts @@ -0,0 +1,98 @@ +/** + * Smoke integration tests for the v2 traces resource. + * + * Covers `Client.traces` (query, listRuns), exposed in #3162. + */ +import { Client } from "../client.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { deleteProject, pollRunsUntilCount, waitUntil } from "./utils.js"; + +async function setUpProjectWithRuns(client: Client) { + const projectName = `__test_v2_traces_${uuidv4().slice(0, 12)}`; + if (await client.hasProject({ projectName })) { + await deleteProject(client, projectName); + } + + const now = new Date(); + await client.createRun({ + name: "run_1", + inputs: { i: 1 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime(), + }); + await client.createRun({ + name: "run_2", + inputs: { i: 2 }, + run_type: "llm", + project_name: projectName, + start_time: now.getTime() + 1000, + }); + await pollRunsUntilCount(client, projectName, 2, 30_000); + + const project = await client.readProject({ projectName }); + const projectId = project.id; + const minStartTime = new Date(now.getTime() - 60 * 60 * 1000).toISOString(); + const maxStartTime = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); + + return { projectName, projectId, minStartTime, maxStartTime }; +} + +test("traces.query returns a page of traces for the project", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, projectId, minStartTime, maxStartTime } = + await setUpProjectWithRuns(client); + try { + let itemCount = 0; + await waitUntil( + async () => { + const page = await client.traces.query({ + project_id: projectId, + page_size: 10, + min_start_time: minStartTime, + max_start_time: maxStartTime, + }); + itemCount = page.items.length; + return itemCount > 0; + }, + 60_000, + 3_000, + ); + expect(itemCount).toBeGreaterThan(0); + } finally { + await deleteProject(client, projectName); + } +}); + +test("traces.listRuns returns the runs belonging to a trace", async () => { + const client = new Client({ + autoBatchTracing: false, + callerOptions: { maxRetries: 6 }, + }); + const { projectName, projectId, minStartTime, maxStartTime } = + await setUpProjectWithRuns(client); + try { + const page = await client.traces.query({ + project_id: projectId, + page_size: 10, + min_start_time: minStartTime, + max_start_time: maxStartTime, + }); + expect(page.items.length).toBeGreaterThan(0); + const rootRun = page.items[0].root_run; + expect(rootRun).toBeDefined(); + const traceId = rootRun!.id; + expect(traceId).toBeDefined(); + + const response = await client.traces.listRuns(traceId!, { + project_id: projectId, + }); + expect(response.items).toBeDefined(); + expect(response.items!.length).toBeGreaterThan(0); + } finally { + await deleteProject(client, projectName); + } +}); diff --git a/src/lib/tests/tracing_mode.test.ts b/src/lib/tests/tracing_mode.test.ts new file mode 100644 index 0000000..1d22ece --- /dev/null +++ b/src/lib/tests/tracing_mode.test.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-process-env */ +import { resolveTracingMode } from "../utils/env.js"; +import { Client } from "../client.js"; + +describe("resolveTracingMode", () => { + const saved = { ...process.env }; + afterEach(() => { + process.env = { ...saved }; + }); + + it("defaults to langsmith", () => { + delete process.env.LANGSMITH_TRACING_MODE; + delete process.env.LANGCHAIN_TRACING_MODE; + delete process.env.OTEL_ENABLED; + delete process.env.LANGSMITH_OTEL_ENABLED; + expect(resolveTracingMode()).toBe("langsmith"); + }); + + it("returns explicit config value", () => { + expect(resolveTracingMode("otel")).toBe("otel"); + expect(resolveTracingMode("langsmith")).toBe("langsmith"); + }); + + it("reads LANGSMITH_TRACING_MODE env var", () => { + process.env.LANGSMITH_TRACING_MODE = "otel"; + expect(resolveTracingMode()).toBe("otel"); + }); + + it("falls back to legacy LANGSMITH_OTEL_ENABLED", () => { + delete process.env.LANGSMITH_TRACING_MODE; + process.env.LANGSMITH_OTEL_ENABLED = "true"; + expect(resolveTracingMode()).toBe("otel"); + }); + + it("explicit config takes precedence over env var", () => { + process.env.LANGSMITH_TRACING_MODE = "otel"; + expect(resolveTracingMode("langsmith")).toBe("langsmith"); + }); + + it("throws on invalid env var value", () => { + process.env.LANGSMITH_TRACING_MODE = "hybrid"; + expect(() => resolveTracingMode()).toThrow( + "Invalid LANGSMITH_TRACING_MODE", + ); + }); +}); + +describe("Client.tracingMode", () => { + it("defaults to langsmith", () => { + const client = new Client({ apiKey: "test-key" }); + expect(client.tracingMode).toBe("langsmith"); + }); + + it("respects explicit tracingMode config", () => { + const client = new Client({ apiKey: "test-key", tracingMode: "otel" }); + expect(client.tracingMode).toBe("otel"); + }); +}); diff --git a/src/lib/tests/utils.ts b/src/lib/tests/utils.ts new file mode 100644 index 0000000..b231450 --- /dev/null +++ b/src/lib/tests/utils.ts @@ -0,0 +1,571 @@ +import { Client } from "../client.js"; +import { v4 as uuidv4 } from "../utils/uuid/src/index.js"; +import { faker } from "@faker-js/faker"; +import { RunCreate } from "../schemas.js"; + +export async function toArray(iterable: AsyncIterable): Promise { + const result: T[] = []; + for await (const item of iterable) { + result.push(item); + } + return result; +} + +export async function waitUntil( + condition: () => Promise, + timeout: number, + interval: number, + prefix?: string, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeout) { + try { + if (await condition()) { + return; + } + } catch (_e) { + // Pass + } + await new Promise((resolve) => setTimeout(resolve, interval)); + } + const elapsed = Date.now() - start; + throw new Error( + [prefix, `Timeout after ${elapsed / 1000}s`].filter(Boolean).join(": "), + ); +} + +/** + * Counts the runs in a project. + * + * `listRuns` comes first so environments that serve it keep the exact behavior + * they had before; SmithDB-only backends don't serve it and never settle, so + * they fall through to the v2 read path. + */ +async function countProjectRuns( + client: Client, + projectName: string, +): Promise { + try { + const count = (await toArray(client.listRuns({ projectName }))).length; + if (count > 0) return count; + } catch (_e) { + // Fall through to the v2 endpoint. + } + try { + const { id } = await client.readProject({ projectName }); + let count = 0; + for await (const _run of client.runs.queryV2({ + project_ids: [id], + selects: ["ID"], + })) { + count++; + } + return count; + } catch (_e) { + return null; + } +} + +/** + * Resolves a run's trace id. + * + * `readRun` comes first to keep the previous behavior where it works; SmithDB + * answers the legacy single-run read with 501, so those deployments fall through + * to the v2 read. Callers create root runs, whose trace id is the run id itself + * — the last resort. + */ +export async function resolveTraceId( + client: Client, + runId: string, + projectId: string, +): Promise { + try { + const run = await client.readRun(runId); + if (run?.trace_id) return run.trace_id; + } catch (_e) { + // Fall through to the v2 endpoint. + } + try { + const run = await client.runs.retrieveV2(runId, { + project_id: projectId, + selects: ["TRACE_ID"], + }); + if (run?.trace_id) return run.trace_id; + } catch (_e) { + // Fall through to the run id. + } + return runId; +} + +export async function pollRunsUntilCount( + client: Client, + projectName: string, + count: number, + timeout?: number, +): Promise { + await waitUntil( + async () => (await countProjectRuns(client, projectName)) === count, + timeout ?? 120_000, // Wait up to 120 seconds + 3000, + ); +} + +export async function deleteProject( + langchainClient: Client, + projectName: string, +) { + try { + await langchainClient.readProject({ projectName }); + await langchainClient.deleteProject({ projectName }); + } catch (_e) { + // Pass + } +} +export async function deleteDataset( + langchainClient: Client, + datasetName: string, +) { + try { + const existingDataset = await langchainClient.readDataset({ datasetName }); + await langchainClient.deleteDataset({ datasetId: existingDataset.id }); + } catch (_e) { + // Pass + } +} + +export async function waitUntilRunFound( + client: Client, + runId: string, + checkOutputs = false, + timeout?: number, +) { + return waitUntil( + async () => { + try { + const run = await client.readRun(runId); + if (checkOutputs) { + return ( + run.outputs !== null && + run.outputs !== undefined && + Object.keys(run.outputs).length !== 0 + ); + } + return true; + } catch (_e) { + return false; + } + }, + timeout ?? 30_000, + 3_000, + `Waiting for run "${runId}"`, + ); +} + +export async function waitUntilRunFoundByMetaField( + client: Client, + projectName: string, + metaKey: string, + metaValue: string, + checkOutputs = false, +) { + return waitUntil( + async () => { + try { + const runs = await toArray( + client.listRuns({ + filter: `and(eq(metadata_key, "${metaKey}"), eq(metadata_value, "${metaValue}"))`, + projectName, + }), + ); + if (runs.length === 0) { + return false; + } + const run = runs[0]; + if (checkOutputs) { + return ( + run.outputs !== null && + run.outputs !== undefined && + Object.keys(run.outputs).length !== 0 + ); + } + return true; + } catch (_e) { + return false; + } + }, + 30_000, + 5_000, + `Waiting for run with metadata.${metaKey} = "${metaValue}"`, + ); +} + +export async function waitUntilProjectFound( + client: Client, + projectName: string, +) { + return waitUntil( + async () => { + try { + await client.readProject({ projectName }); + return true; + } catch (_e) { + return false; + } + }, + 15_000, + 5_000, + `Waiting for project "${projectName}"`, + ); +} + +export function sanitizePresignedUrls(payload: unknown) { + return JSON.parse(JSON.stringify(payload), (key, value) => { + if (key === "presigned_url") { + try { + const url = new URL(value); + url.searchParams.set("jwt", "[JWT]"); + url.searchParams.set("Signature", "[SIGNATURE]"); + url.searchParams.set("Expires", "[EXPIRES]"); + return url.toString(); + } catch { + return value; + } + } + return value; + }); +} + +/** + * Factory which returns a list of `RunCreate` objects. + * @param {number} count Number of runs to create (default: 10) + * @returns {Array} List of `RunCreate` objects + */ +export function createRunsFactory( + projectName: string, + count = 10, +): Array { + return Array.from({ length: count }).map((_, idx) => ({ + id: uuidv4(), + name: `${idx}-${faker.lorem.words()}`, + run_type: faker.helpers.arrayElement(["tool", "chain", "llm", "retriever"]), + inputs: { + question: faker.lorem.sentence(), + }, + outputs: { + answer: faker.lorem.sentence(), + }, + project_name: projectName, + })); +} + +/** + * Helper to check if an error is a rate limit (429) error + */ +export function isRateLimitError(error: any): boolean { + return ( + error?.status === 429 || + error?.statusCode === 429 || + error?.message?.includes("429") || + error?.message?.includes("Too Many Requests") || + error?.message?.includes("Rate limit") + ); +} + +/** + * Helper to check if an error is a timeout error + */ +export function isTimeoutError(error: any): boolean { + const message = error?.message || ""; + return ( + message.includes("Timeout") || + message.includes("timeout") || + message.includes("ETIMEDOUT") || + message.includes("Exceeded timeout") + ); +} + +/** + * Helper to check if an error is a transient/flaky error that can be skipped in CI. + * This includes rate limit errors, timeouts, and other transient issues. + */ +export function isTransientError(error: any): boolean { + return isRateLimitError(error) || isTimeoutError(error); +} + +/** + * Wraps a test function to skip gracefully on rate limit errors + */ +export async function skipOnRateLimit( + testFn: () => Promise, +): Promise { + try { + await testFn(); + } catch (error: any) { + if (isRateLimitError(error)) { + console.log( + "⚠️ Test skipped due to rate limit (429). This is expected in CI with API rate limits.", + ); + return; // Skip test gracefully + } + throw error; // Re-throw other errors + } +} + +/** + * Wraps a test function to skip gracefully on transient errors + * (rate limits, timeouts, and other CI-flaky issues) + */ +export async function skipIfTransientError( + testFn: () => Promise, +): Promise { + try { + await testFn(); + } catch (error: any) { + if (isTransientError(error)) { + const errorType = isRateLimitError(error) ? "rate limit" : "timeout"; + console.log( + `⚠️ Test skipped due to ${errorType}. This is expected in CI with API rate limits.`, + ); + return; // Skip test gracefully + } + throw error; // Re-throw other errors + } +} +export const generateLongContext = () => { + return ` +The current date is ${new Date().toISOString()}. + +COMPREHENSIVE ENTERPRISE APPLICATION ERROR ANALYSIS AND SYSTEM DEBUGGING DOCUMENTATION: + +================================================================================ +PRIMARY ERROR INFORMATION +================================================================================ + +TypeError: Cannot read properties of undefined (reading 'map') + at processUserDataCollection (/app/src/utils/dataProcessor.js:45:22) + at validateUserInputCollection (/app/src/validation/userValidator.js:78:15) + at handleApiResponseProcessing (/app/src/handlers/apiHandler.js:123:8) + at processApiDataTransformation (/app/src/services/apiService.js:234:12) + at async fetchUserDataFromMultipleSources (/app/src/services/userService.js:89:19) + at async getUserProfileWithPreferences (/app/src/controllers/userController.js:156:23) + at async authenticateUserAndLoadSession (/app/src/middleware/auth.js:67:18) + at async initializeApplicationMainProcess (/app/src/index.js:12:5) + at Object. (/app/src/index.js:25:1) + at Module._compile (node:internal/modules/cjs/loader:1469:14) + at Module._extensions..js (node:internal/modules/cjs/loader:1548:10) + at Module.load (node:internal/modules/cjs/loader:1288:32) + at Module._load (node:internal/modules/cjs/loader:1104:12) + at Function.executeUserEntryPoint [as runMain] (node:internal/run_main:154:12) + at node:internal/main/run_main_module:28:49 + +================================================================================ +DETAILED SYSTEM ARCHITECTURE AND ERROR CONTEXT +================================================================================ + +This critical error manifests in a complex enterprise-grade Node.js application that processes user data from multiple distributed API endpoints across various microservices. The application architecture encompasses several interconnected systems that communicate through REST APIs, GraphQL endpoints, message queues, and real-time WebSocket connections. The specific error occurs when the dataProcessor utility function attempts to execute the JavaScript .map() method on what the system expects to be an array of user objects, but instead receives an undefined value due to various upstream failures or race conditions. + +The application serves over 100,000 daily active users across multiple geographic regions and handles complex data transformations including user profile management, preference synchronization, analytics tracking, financial transactions, content delivery, and real-time notifications. The system architecture follows microservices patterns with event-driven communication, distributed caching, and horizontal scaling capabilities. + +================================================================================ +COMPREHENSIVE SYSTEM ENVIRONMENT AND INFRASTRUCTURE +================================================================================ + +Production Environment Configuration: +- Node.js Runtime Version: 18.17.0 (LTS with security patches) +- Express.js Framework Version: 4.18.2 with custom middleware +- Database Primary: PostgreSQL 15.3 with read replicas +- Database Secondary: MongoDB 6.0.8 for document storage +- Cache Layer Primary: Redis 7.0.11 cluster with 6 nodes +- Cache Layer Secondary: Memcached 1.6.17 for session data +- Operating System: Ubuntu 22.04 LTS with hardened kernel +- Container Runtime: Docker 24.0.5 with Kubernetes 1.27.3 +- Memory Allocation: 8GB heap size with 2GB for V8 garbage collection +- CPU Resources: 16 virtual cores across 4 physical cores +- Load Balancer: NGINX 1.22.1 with SSL termination and compression +- Reverse Proxy: HAProxy 2.8.1 for advanced load balancing +- Message Queue: Apache Kafka 3.5.0 with 3 broker cluster +- Search Engine: Elasticsearch 8.8.2 with 5-node cluster +- Monitoring: Prometheus 2.45.0 with Grafana 10.0.2 dashboards +- Logging: ELK Stack with Filebeat, Logstash, and centralized logging +- Security: OAuth 2.0, JWT tokens, API rate limiting, DDoS protection + +Development and Staging Environments: +- Docker Compose with service replication +- Kubernetes minikube for local development +- Jenkins CI/CD pipeline with automated testing +- SonarQube code quality analysis +- Snyk security vulnerability scanning +- Jest test framework with 85%+ code coverage +- ESLint and Prettier for code formatting +- Husky pre-commit hooks for quality gates + +================================================================================ +ENTERPRISE APPLICATION ARCHITECTURE OVERVIEW +================================================================================ + +The application implements a sophisticated microservices architecture with the following core components and their responsibilities: + +1. API Gateway Service (Port 3000): + - Handles all incoming HTTP requests and routing decisions + - Implements rate limiting with Redis-backed token buckets + - Manages API versioning and backward compatibility + - Enforces authentication and authorization policies + - Provides request/response transformation and validation + - Implements circuit breakers for downstream service protection + - Tracks API usage metrics and performance analytics + +2. Authentication and Authorization Service (Port 3001): + - JWT token generation, validation, and refresh mechanisms + - OAuth 2.0 integration with Google, Facebook, and Microsoft + - Multi-factor authentication with SMS and email verification + - Role-based access control (RBAC) with fine-grained permissions + - Session management with Redis-based storage + - Password policies and security audit logging + - Single Sign-On (SSO) integration with SAML and OpenID Connect + +3. User Profile Management Service (Port 3002): + - Comprehensive user profile data management and CRUD operations + - User preference storage and synchronization across devices + - Avatar and file upload handling with AWS S3 integration + - User activity tracking and behavioral analytics + - Privacy settings management and GDPR compliance features + - User relationship management (friends, followers, connections) + - Advanced search and filtering capabilities with Elasticsearch + +4. Data Processing and Transformation Service (Port 3003): + - Real-time data validation and sanitization pipelines + - Complex data transformation workflows with configurable rules + - ETL processes for data warehouse synchronization + - Machine learning model integration for data enrichment + - Batch processing jobs with queue management + - Data quality monitoring and anomaly detection + - Integration with external data sources and APIs + +5. Notification and Communication Service (Port 3004): + - Multi-channel notification delivery (email, SMS, push, in-app) + - Template management system with dynamic content rendering + - Delivery tracking and analytics with engagement metrics + - Preference-based notification filtering and scheduling + - Integration with SendGrid, Twilio, and Firebase Cloud Messaging + - Real-time WebSocket notifications for instant messaging + - Notification history and audit trails for compliance + +6. Analytics and Reporting Service (Port 3005): + - Real-time user behavior tracking and event collection + - Custom dashboard creation with interactive visualizations + - A/B testing framework with statistical significance analysis + - Business intelligence reporting with scheduled delivery + - Data export capabilities in multiple formats (CSV, JSON, PDF) + - Performance monitoring and application health metrics + - Integration with Google Analytics and third-party tracking tools + +7. File Storage and Content Delivery Service (Port 3006): + - Scalable file upload and download management + - Image processing and optimization with multiple size variants + - Content Delivery Network (CDN) integration with CloudFront + - File metadata extraction and search indexing + - Virus scanning and security validation for uploads + - Backup and disaster recovery with cross-region replication + - Digital asset management with version control + +8. Distributed Cache and Session Management Layer: + - Redis cluster for high-performance caching and session storage + - Cache invalidation strategies with event-driven updates + - Distributed locking mechanisms for critical sections + - Session replication across multiple application instances + - Cache warming strategies for improved performance + - Memory optimization and garbage collection tuning + - Cache analytics and performance monitoring + +================================================================================ +ERROR FREQUENCY ANALYSIS AND OPERATIONAL METRICS +================================================================================ + +Detailed Error Occurrence Patterns: +- Primary occurrence rate: 15-20 instances per hour during peak traffic periods +- Secondary spikes: 45-60 instances per hour during system maintenance windows +- Geographic distribution: 40% US East, 30% Europe, 20% Asia Pacific, 10% other regions +- Time-based patterns: Higher frequency during business hours (9 AM - 6 PM local time) +- User impact correlation: Affects 2-3% of concurrent user sessions during peak load +- System load correlation: Directly proportional to concurrent user count (>1000 users) +- Database performance correlation: Coincides with connection pool exhaustion events +- Third-party API correlation: Often occurs during external service rate limiting periods +- Recovery characteristics: Automatic recovery typically occurs within 30-60 seconds +- Manual intervention requirements: Required in <5% of cases for complete resolution + +Performance Impact Metrics: +- Average response time increase: 2.5x normal during error periods +- CPU utilization spike: 85-95% during error handling +- Memory consumption increase: 40-60% above baseline +- Database connection pool usage: 90-100% utilization +- Cache hit ratio degradation: 15-25% reduction +- Network bandwidth utilization: 30% increase due to retry logic +- Error rate propagation: Cascades to 3-4 downstream services +- User session termination rate: 8-12% of affected sessions + +================================================================================ +DETAILED CODE ANALYSIS AND ROOT CAUSE INVESTIGATION +================================================================================ + +The problematic code section located in /app/src/utils/dataProcessor.js (lines 40-55): + +function processUserDataCollection(userData, processingOptions = {}) { + // Critical error location: userData parameter validation missing + // This function expects an array but sometimes receives undefined + // due to upstream service failures or race conditions + + const { + enableValidation = true, + transformationRules = [], + sanitizationLevel = 'standard', + auditLogging = false + } = processingOptions; + + if (auditLogging) { + logger.info('Processing user data collection', { + dataLength: userData?.length, + options: processingOptions, + timestamp: new Date().toISOString() + }); + } + + // ERROR OCCURS HERE: userData.map() called on undefined value + return userData.map(user => ({ + id: user.id, + name: sanitizeName(user.name, sanitizationLevel), + email: validateEmail(user.email, enableValidation), + preferences: processPreferences(user.preferences, transformationRules), + metadata: enrichMetadata(user.metadata), + profileImage: processImageUrls(user.profileImage), + lastLoginTime: formatTimestamp(user.lastLoginTime), + accountStatus: determineAccountStatus(user), + permissions: calculateUserPermissions(user.roles), + analyticsData: generateAnalyticsMetadata(user) + })); +} + +Related upstream code in /app/src/services/userService.js (lines 85-105): + +async function fetchUserDataFromMultipleSources(userIds, options = {}) { + try { + const promises = [ + fetchFromPrimaryDatabase(userIds), + fetchFromCacheLayer(userIds), + fetchFromExternalAPIs(userIds, options) + ]; + + const [dbData, cacheData, apiData] = await Promise.allSettled(promises); + + // POTENTIAL ISSUE: Merge logic may result in undefined + const mergedData = mergeUserDataSources(dbData, cacheData, apiData); + + // ISSUE: No validation that mergedData is a valid array + return mergedData; + } catch (error) { + logger.error('Failed to fetch user data from multiple sources', error); + // CRITICAL ISSUE: Returns undefined instead of empty array + return undefined; + } +} + `.trim(); +}; diff --git a/src/lib/tests/utils/markers.ts b/src/lib/tests/utils/markers.ts new file mode 100644 index 0000000..074e91c --- /dev/null +++ b/src/lib/tests/utils/markers.ts @@ -0,0 +1,64 @@ +import { describe, test } from "@jest/globals"; + +import { getLangSmithEnvironmentVariable } from "../../utils/env.js"; + +/** + * Environment gating for the integration suite, mirroring the pytest markers on + * the Python side. + * + * CI passes the markers the target deployment cannot serve through + * LANGSMITH_TEST_EXCLUDE_MARKERS (a comma-separated list built per environment + * in .github/workflows/ci.yml); the tests carrying those markers skip. With the + * variable unset — local runs and beta — nothing is skipped. + */ +const excluded = new Set( + (getLangSmithEnvironmentVariable("TEST_EXCLUDE_MARKERS") ?? "") + .split(",") + .map((marker) => marker.trim()) + .filter(Boolean), +); + +/** + * Returns `test` / `describe` for the given marker, swapped for their `.skip` + * variants when the target environment excludes it. + */ +export function requires(...markers: string[]): { + test: typeof test; + it: typeof test; + describe: typeof describe; +} { + if (!markers.some((marker) => excluded.has(marker))) + return { test, it: test, describe }; + const skippedTest = skipped(test); + return { test: skippedTest, it: skippedTest, describe: skipped(describe) }; +} + +/** + * A `test`/`describe` stand-in that skips everything but keeps the modifier + * surface (`.skip`, `.only`, `.each`, ...) the test files already use. + */ +function skipped(fn: T): T { + const skip = fn.skip as unknown as (...args: unknown[]) => unknown; + return Object.assign((...args: unknown[]) => skip(...args), fn, { + only: fn.skip, + each: fn.skip.each, + }) as unknown as T; +} + +/** Needs the v2 (SmithDB) endpoints, which V15 deployments don't serve. */ +export const requiresV2 = requires("require_v2"); + +/** Needs a legacy ClickHouse-backed endpoint, absent on SmithDB-only backends. */ +export const requiresClickhouse = requires("require_clickhouse"); + +/** + * Needs a dataset that only exists on the beta tenant — a hardcoded share token + * or a public dataset URL that no self-hosted deployment can resolve. + */ +export const requiresBetaDataset = requires("require_beta_dataset"); + +/** + * Exercises an LLM provider wrapper against a mocked LangSmith client, so the + * target deployment plays no part. Run once, on beta. + */ +export const requiresProvider = requires("require_provider"); diff --git a/src/lib/tests/utils/mock_client.ts b/src/lib/tests/utils/mock_client.ts new file mode 100644 index 0000000..2a905dc --- /dev/null +++ b/src/lib/tests/utils/mock_client.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { jest } from "@jest/globals"; +import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain"; +import { Client } from "../../index.js"; + +type ClientParams = Exclude[0], undefined>; +export const mockClient = (config?: Omit) => { + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(""), + json: () => Promise.resolve({}), + } as Response); + + const client = new Client({ + ...config, + apiKey: "MOCK", + autoBatchTracing: false, + fetchImplementation: mockFetch, + }); + + const callSpy = mockFetch; + + const langChainTracer = new LangChainTracer({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Overriden client + client, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { client, callSpy: callSpy as any, langChainTracer }; +}; diff --git a/src/lib/tests/utils/tree.ts b/src/lib/tests/utils/tree.ts new file mode 100644 index 0000000..e24df3b --- /dev/null +++ b/src/lib/tests/utils/tree.ts @@ -0,0 +1,126 @@ +import { Run } from "../../schemas.js"; +import { Client } from "../../client.js"; + +export async function getAssumedTreeFromCalls( + calls: unknown[][], + client: Client, +): Promise<{ + nodes: string[]; + edges: Array<[string, string]>; + data: Record; +}> { + await client.awaitPendingTraceBatches(); + + const edges: Array<[string, string]> = []; + + const nodeMap: Record = {}; + const idMap: string[] = []; + + function upsertId(id: string) { + const idx = idMap.indexOf(id); + if (idx < 0) { + idMap.push(id); + return idMap.length - 1; + } + return idx; + } + + function getId(id: string) { + const stableId = upsertId(id); + + const name = nodeMap[id].name; + return [name, stableId].join(":"); + } + + for (let i = 0; i < calls.length; ++i) { + const call = calls[i]; + + const [url, fetchArgs] = call.slice(-2) as [ + string, + { method: string; body: string }, + ]; + const { method } = fetchArgs; + // The API base URL carries a different prefix per deployment ("" on beta, + // "/api" on self-hosted, "/api/v1" elsewhere), so match on the endpoint + // suffix rather than the whole pathname. + const pathname = new URL(url as string).pathname; + const createRun = method === "POST" && /(?:^|\/)runs$/.test(pathname); + const updateRun = + method === "PATCH" && /(?:^|\/)runs\/[^/]+$/.test(pathname); + let body: Run; + if (typeof fetchArgs.body === "string") { + body = JSON.parse(fetchArgs.body); + } else { + const decoded = new TextDecoder().decode(fetchArgs.body); + if (decoded.trim().startsWith("{")) { + body = JSON.parse(decoded); + } + } + + if (createRun) { + const id = body!.id; + upsertId(id); + nodeMap[id] = { ...nodeMap[id], ...body! }; + if (nodeMap[id].parent_run_id) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + edges.push([nodeMap[id].parent_run_id!, nodeMap[id].id]); + } + } else if (updateRun) { + const id = pathname.slice(pathname.lastIndexOf("/") + 1); + upsertId(id); + nodeMap[id] = { ...nodeMap[id], ...body! }; + } + } + + return { + nodes: idMap.map(getId), + edges: edges.map(([source, target]) => [getId(source), getId(target)]), + data: Object.fromEntries( + Object.entries(nodeMap).map(([id, value]) => [getId(id), value] as const), + ), + }; +} + +type MagicRunResult = { + name: string; + [key: string]: unknown; +}; + +type MagicRun = ( + rawName: TemplateStringsArray, +) => (props: Record, ...children: string[]) => string; + +export function asTree(cb: (run: MagicRun) => void): { + nodes: string[]; + edges: Array<[string, string]>; + data: Record; +} { + const acc: { + nodes: string[]; + edges: Array<[string, string]>; + data: Record; + } = { nodes: [], edges: [], data: {} }; + + function run(rawId: TemplateStringsArray) { + const id = rawId.join(""); + const name = id.split(":")[0]; + + acc.nodes.push(id); + return (props: Record, ...children: string[]): string => { + for (const childId of children) acc.edges.push([id, childId]); + acc.data[id] = { name, ...props }; + return id; + }; + } + + cb(run); + const nodeOrder = new Map(acc.nodes.map((id, idx) => [id, idx])); + + return { + ...acc, + edges: [...acc.edges].sort( + ([, left], [, right]) => + (nodeOrder.get(left) ?? 0) - (nodeOrder.get(right) ?? 0), + ), + }; +} diff --git a/src/lib/tests/utils/vitest_mock_client.ts b/src/lib/tests/utils/vitest_mock_client.ts new file mode 100644 index 0000000..739b467 --- /dev/null +++ b/src/lib/tests/utils/vitest_mock_client.ts @@ -0,0 +1,33 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { vi } from "vitest"; +import { LangChainTracer } from "@langchain/core/tracers/tracer_langchain"; +import { Client } from "../../index.js"; + +type ClientParams = Exclude[0], undefined>; +export const mockClient = (config?: Omit) => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: () => Promise.resolve(""), + json: () => Promise.resolve({}), + } as Response); + + const client = new Client({ + ...config, + apiKey: "MOCK", + autoBatchTracing: false, + fetchImplementation: mockFetch, + }); + + const callSpy = mockFetch; + + const langChainTracer = new LangChainTracer({ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore Overriden client + client, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { client, callSpy: callSpy as any, langChainTracer }; +}; diff --git a/src/lib/tests/v2_migration.test.ts b/src/lib/tests/v2_migration.test.ts new file mode 100644 index 0000000..7fb947f --- /dev/null +++ b/src/lib/tests/v2_migration.test.ts @@ -0,0 +1,62 @@ +import type { Run as V2Run } from "../../resources/runs/runs.js"; +import type { TracerSession } from "../schemas.js"; +import { + getQueryBackend, + QueryBackend, + v2RunToRun, +} from "../utils/v2_migration.js"; + +const project: TracerSession = { + id: "00000000-0000-0000-0000-000000000001", + tenant_id: "00000000-0000-0000-0000-000000000002", + start_time: 0, + end_time: 1_000, +}; + +const v2Run: V2Run = { + id: "00000000-0000-0000-0000-000000000003", + name: "root", + run_type: "CHAIN", + start_time: "1970-01-01T00:00:00.000Z", + trace_id: "00000000-0000-0000-0000-000000000003", + project_id: project.id, + parent_run_ids: [], + reference_example_id: "00000000-0000-0000-0000-000000000004", + inputs: { question: "test" }, + outputs: { answer: "test" }, + status: "SUCCESS", +}; + +describe("v2 migration utils", () => { + test.each([ + [undefined, QueryBackend.CLICKHOUSE_ONLY], + [{}, QueryBackend.CLICKHOUSE_ONLY], + [{ ch_query_enabled: true }, QueryBackend.CLICKHOUSE_ONLY], + [{ sdb_query_enabled: true }, QueryBackend.DUAL], + [{ ch_query_enabled: true, sdb_query_enabled: true }, QueryBackend.DUAL], + [ + { ch_query_enabled: false, sdb_query_enabled: true }, + QueryBackend.SMITHDB_ONLY, + ], + [ + { ch_query_enabled: false, sdb_query_enabled: false }, + QueryBackend.CLICKHOUSE_ONLY, + ], + ])("getQueryBackend(%j) -> %s", (flags, expected) => { + expect(getQueryBackend(flags)).toBe(expected); + }); + + test("maps generated v2 runs to legacy runs", () => { + expect(v2RunToRun(v2Run)).toEqual( + expect.objectContaining({ + id: v2Run.id, + session_id: project.id, + run_type: "chain", + status: "success", + parent_run_id: undefined, + inputs: v2Run.inputs, + outputs: v2Run.outputs, + }), + ); + }); +}); diff --git a/src/lib/tests/vercel/telemetry/anthropic.int.test.ts b/src/lib/tests/vercel/telemetry/anthropic.int.test.ts new file mode 100644 index 0000000..28beb01 --- /dev/null +++ b/src/lib/tests/vercel/telemetry/anthropic.int.test.ts @@ -0,0 +1,590 @@ +import { jest } from "@jest/globals"; +import { + anthropic as defaultAnthropic, + createAnthropic, +} from "@ai-sdk/anthropic"; +import * as ai from "ai"; +import z from "zod"; + +import { LangSmithTelemetry } from "../../../experimental/vercel/telemetry.js"; +import { generateLongContext } from "../../utils.js"; +import { getAssumedTreeFromCalls } from "../../utils/tree.js"; +import { Client } from "../../../index.js"; + +const { tool, stepCountIs } = ai; + +// The provider posts to `${baseURL}/messages`; the LLM gateway serves +// `/anthropic/v1/messages`, so a gateway base URL needs the `/v1` added. +const anthropic = process.env.ANTHROPIC_BASE_URL + ? createAnthropic({ baseURL: `${process.env.ANTHROPIC_BASE_URL}/v1` }) + : defaultAnthropic; + +test("telemetry generateText", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const result = await ai.generateText({ + model: anthropic("claude-haiku-4-5"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + }); + + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [ + ["anthropic.messages:0", "anthropic.messages:1"], + ["anthropic.messages:0", "listOrders:2"], + ["anthropic.messages:0", "anthropic.messages:3"], + ], + data: { + "anthropic.messages:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: ["listOrders"], + }, + outputs: { + content: expect.stringMatching(/order/i), + finish_reason: "stop", + }, + }, + "anthropic.messages:1": { + run_type: "llm", + extra: { + invocation_params: { + tools: [ + expect.objectContaining({ + name: "listOrders", + input_schema: expect.objectContaining({ type: "object" }), + }), + ], + }, + }, + inputs: { + messages: [ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + ], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "listOrders", + }), + ]), + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "listOrders" }), + }), + ]), + finish_reason: "tool-calls", + }, + }, + "listOrders:2": { + run_type: "tool", + inputs: { userId: "123" }, + outputs: { + output: expect.stringMatching(/User 123 has the following orders/), + }, + }, + "anthropic.messages:3": { + run_type: "llm", + inputs: { + messages: expect.arrayContaining([ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + expect.objectContaining({ role: "assistant" }), + expect.objectContaining({ role: "tool" }), + ]), + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/order/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); + + expect( + runs.data["anthropic.messages:1"].extra?.invocation_params?.tools?.[0], + ).not.toHaveProperty("inputSchema"); + + expect(runs.data["anthropic.messages:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect( + runs.data["anthropic.messages:1"].extra?.metadata?.usage_metadata + ?.total_tokens, + ).toBeGreaterThan(0); + expect( + runs.data["anthropic.messages:3"].extra?.metadata?.usage_metadata + ?.total_tokens, + ).toBeGreaterThan(0); +}); + +test("telemetry streamText", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const streamResult = ai.streamText({ + model: anthropic("claude-haiku-4-5"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + }); + + let total = ""; + for await (const chunk of streamResult.textStream) { + total += chunk; + } + expect(total).toBeDefined(); + expect(total.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [ + ["anthropic.messages:0", "anthropic.messages:1"], + ["anthropic.messages:0", "listOrders:2"], + ["anthropic.messages:0", "anthropic.messages:3"], + ], + data: { + "anthropic.messages:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: ["listOrders"], + }, + outputs: { + content: expect.stringMatching(/order/i), + finish_reason: "stop", + }, + }, + "anthropic.messages:1": { + run_type: "llm", + extra: { + invocation_params: { + tools: [ + expect.objectContaining({ + name: "listOrders", + input_schema: expect.objectContaining({ type: "object" }), + }), + ], + }, + }, + inputs: { + messages: [ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + ], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "listOrders", + }), + ]), + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "listOrders" }), + }), + ]), + finish_reason: "tool-calls", + }, + }, + "listOrders:2": { + run_type: "tool", + inputs: { userId: "123" }, + outputs: { + output: expect.stringMatching(/User 123 has the following orders/), + }, + }, + "anthropic.messages:3": { + run_type: "llm", + inputs: { + messages: expect.arrayContaining([ + { + role: "user", + content: + "What are my orders? My user ID is 123. Always use tools.", + }, + expect.objectContaining({ role: "assistant" }), + expect.objectContaining({ role: "tool" }), + ]), + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/order/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); + + expect( + runs.data["anthropic.messages:1"].extra?.invocation_params?.tools?.[0], + ).not.toHaveProperty("inputSchema"); +}, 30_000); + +test("telemetry generateObject", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const schema = z.object({ color: z.string() }); + const result = await ai.generateText({ + model: anthropic("claude-haiku-4-5"), + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + output: ai.Output.object({ schema }), + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.output).toBeDefined(); + expect(schema.parse(result.output)).toBeDefined(); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [["anthropic.messages:0", "anthropic.messages:1"]], + data: { + "anthropic.messages:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + }, + outputs: { + content: expect.stringMatching(/"color"\s*:\s*"blue"/i), + finish_reason: "stop", + }, + }, + "anthropic.messages:1": { + run_type: "llm", + inputs: { + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/"color"\s*:\s*"blue"/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); +}); + +test("telemetry streamObject via streamText with output", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const schema = z.object({ + color: z.string(), + }); + + const streamResult = ai.streamText({ + model: anthropic("claude-haiku-4-5"), + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + output: ai.Output.object({ schema }), + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + const chunks = []; + for await (const chunk of streamResult.partialOutputStream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(schema.parse(chunks.at(-1))).toBeDefined(); + + await client.awaitPendingTraceBatches(); + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [["anthropic.messages:0", "anthropic.messages:1"]], + data: { + "anthropic.messages:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + }, + outputs: { + content: expect.stringMatching(/"color"\s*:\s*"blue"/i), + finish_reason: "stop", + }, + }, + "anthropic.messages:1": { + run_type: "llm", + inputs: { + messages: [ + { + role: "user", + content: "What color is the sky? Respond with JSON only.", + }, + ], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/"color"\s*:\s*"blue"/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); +}, 30_000); + +test("telemetry stream cancellation should finish spans cleanly", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const abortController = new AbortController(); + + const streamResult = ai.streamText({ + model: anthropic("claude-haiku-4-5"), + messages: [ + { + role: "user", + content: "Tell me a long story about a cat.", + }, + ], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + abortSignal: abortController.signal, + }); + + let i = 0; + try { + for await (const chunk of streamResult.fullStream) { + if (chunk.type === "text-delta") { + if (i++ > 5) { + abortController.abort(); + } + } + } + } catch { + // Abort will throw + } + + await client.awaitPendingTraceBatches(); + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [["anthropic.messages:0", "anthropic.messages:1"]], + data: { + "anthropic.messages:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: "Tell me a long story about a cat.", + }, + ], + }, + }, + "anthropic.messages:1": { + run_type: "llm", + inputs: { + messages: [ + { + role: "user", + content: "Tell me a long story about a cat.", + }, + ], + }, + }, + }, + }); + + // Runs are created but not closed on abort (no end_time, no outputs) + expect(runs.data["anthropic.messages:0"].end_time).toBeUndefined(); + expect(runs.data["anthropic.messages:1"].end_time).toBeUndefined(); +}); + +// Skipped due to high token usage +test.skip("anthropic cache read and write tokens", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const errorMessage = generateLongContext(); + + const res = await ai.generateText({ + model: anthropic("claude-sonnet-4-6"), + messages: [ + { + role: "user", + content: [ + { type: "text", text: "You are a JavaScript expert." }, + { + type: "text", + text: `Error message: ${errorMessage}`, + providerOptions: { + anthropic: { cacheControl: { type: "ephemeral" } }, + }, + }, + { type: "text", text: "Explain this error message briefly." }, + ], + }, + ], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(res.text).toBeDefined(); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [["anthropic.messages:0", "anthropic.messages:1"]], + data: { + "anthropic.messages:0": { run_type: "chain" }, + "anthropic.messages:1": { run_type: "llm" }, + }, + }); + + expect( + runs.data["anthropic.messages:1"]?.extra?.metadata?.usage_metadata + ?.input_tokens, + ).toBeGreaterThan(0); + + expect( + runs.data["anthropic.messages:1"]?.extra?.metadata?.usage_metadata + ?.input_token_details?.cache_creation, + ).toBeGreaterThan(0); +}); diff --git a/src/lib/tests/vercel/telemetry/harness-agent.int.test.ts b/src/lib/tests/vercel/telemetry/harness-agent.int.test.ts new file mode 100644 index 0000000..eb6cb7d --- /dev/null +++ b/src/lib/tests/vercel/telemetry/harness-agent.int.test.ts @@ -0,0 +1,731 @@ +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createPi } from "@ai-sdk/harness-pi"; +import { createJustBashSandbox } from "@ai-sdk/sandbox-just-bash"; +import { tool } from "ai"; +import { jest } from "@jest/globals"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { Client } from "../../../client.js"; +import { LangSmithTelemetry } from "../../../experimental/vercel/telemetry.js"; +import { toArray, waitUntil } from "../../utils.js"; +import { getAssumedTreeFromCalls } from "../../utils/tree.js"; + +const PROJECT_NAME = "js-ai-sdk-harness-pi-integration"; +const ROOT_RUN_NAME = "AI SDK HarnessAgent with Pi"; +const TASK_FILE_NAME = "task-result.txt"; +const TASK_FILE_CONTENT = "HarnessAgent and Pi completed this task."; +const TASK_FINAL_RESPONSE = "TASK_COMPLETE"; + +function hasNonEmptyTextOutput(outputs: unknown): boolean { + if (outputs == null || typeof outputs !== "object") return false; + const content = (outputs as { content?: unknown }).content; + return ( + Array.isArray(content) && + content.some( + (part) => + part != null && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" && + (part as { text: string }).text.trim().length > 0, + ) + ); +} + +function expectNonEmptyTextOutput(outputs: unknown) { + expect(outputs).toMatchObject({ + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/\S/), + }), + ]), + }); +} + +function hasCompletedLlmTelemetry(run: { + outputs?: unknown; + end_time?: string | number; + extra?: { metadata?: Record }; +}): boolean { + return ( + run.end_time != null && + hasNonEmptyTextOutput(run.outputs) && + typeof run.extra?.metadata?.usage_metadata?.total_tokens === "number" + ); +} + +function expectCompleteToolHistory(inputs: unknown) { + expect(inputs).toMatchObject({ + messages: [{ role: "user" }, { role: "assistant" }, { role: "tool" }], + }); + + const messages = (inputs as { messages: unknown[] }).messages; + const toolCalls = (messages[1] as { tool_calls?: Array<{ id?: unknown }> }) + .tool_calls; + expect(Array.isArray(toolCalls)).toBe(true); + const toolCallId = toolCalls?.[0]?.id; + expect(typeof toolCallId).toBe("string"); + expect(messages[2]).toMatchObject({ + role: "tool", + content: expect.any(String), + tool_call_id: toolCallId, + name: "bash", + artifact: expect.anything(), + }); + expect(JSON.stringify(messages[2])).toContain(TASK_FILE_CONTENT); +} + +function expectToolMessageRun( + run: { + name?: string; + run_type?: string; + parent_run_id?: string | null; + inputs?: unknown; + outputs?: unknown; + }, + rootRunId: string, +) { + expect(run).toMatchObject({ + name: "bash", + run_type: "tool", + parent_run_id: rootRunId, + inputs: expect.anything(), + outputs: { output: expect.any(String) }, + }); + expect(run.inputs).not.toHaveProperty("role"); + expect(run.inputs).not.toHaveProperty("type"); + expect(run.inputs).not.toHaveProperty("toolCallId"); + expect(run.inputs).not.toHaveProperty("toolName"); + expect(JSON.stringify(run.inputs)).toContain(TASK_FILE_NAME); + expect(JSON.stringify(run.outputs)).toContain(TASK_FILE_CONTENT); +} + +test.skip("uploads a real HarnessAgent and Pi trace", async () => { + const testRunId = randomUUID(); + const currentEnv = Reflect.get(process, "env") as NodeJS.ProcessEnv; + const anthropicApiKey = currentEnv.ANTHROPIC_API_KEY; + if (!anthropicApiKey) { + throw new Error("ANTHROPIC_API_KEY is required for this integration test"); + } + + jest.replaceProperty(process, "env", { + ...currentEnv, + TRACE_TO_LANGSMITH: "true", + }); + + const sessionId = `harness-integration-${testRunId}`; + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + const agent = new HarnessAgent({ + harness: createPi({ + auth: { customEnv: { ANTHROPIC_API_KEY: anthropicApiKey } }, + model: "claude-haiku-4-5", + thinkingLevel: "off", + }), + // just-bash 2.14 does not propagate per-command environment variables into + // nested `bash -c` calls. Seed the known work directory globally until the + // minimum-release-age constraint allows a version containing that fix. + sandbox: createJustBashSandbox({ + env: { WORK_DIR: `/home/user/pi-${sessionId}` }, + }), + telemetry: { + integrations: [ + LangSmithTelemetry({ + client, + tracingEnabled: true, + name: ROOT_RUN_NAME, + projectName: PROJECT_NAME, + metadata: { + integration_test: "ai-sdk-harness-pi", + test_run_id: testRunId, + }, + tags: ["integration-test", "ai-sdk-harness", "pi"], + }), + ], + }, + }); + + try { + const session = await agent.createSession({ sessionId }); + + try { + const result = await agent.generate({ + session, + prompt: [ + `Use the bash tool to create ${TASK_FILE_NAME} in the workspace.`, + `The file must contain exactly: ${TASK_FILE_CONTENT}`, + "Print the file contents in the same bash command to verify the write.", + `After writing the file, reply with exactly ${TASK_FINAL_RESPONSE}.`, + ].join("\n"), + }); + expect(result.text.trim()).toBe(TASK_FINAL_RESPONSE); + expect(JSON.stringify(result.toolResults)).toContain(TASK_FILE_CONTENT); + } finally { + await session.destroy(); + } + + await client.awaitPendingTraceBatches(); + + await waitUntil( + async () => { + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const runs = Object.values(tree.data); + return ( + runs.some( + (run) => + run.name === ROOT_RUN_NAME && + JSON.stringify(run.outputs).includes(TASK_FINAL_RESPONSE), + ) && + runs.some((run) => run.name === "bash" && run.run_type === "tool") + ); + }, + 10_000, + 100, + "Waiting for HarnessAgent telemetry writes", + ); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const outboundRuns = Object.values(tree.data); + const harnessRoot = outboundRuns.find( + (run) => run.name === ROOT_RUN_NAME && run.parent_run_id == null, + ); + if (!harnessRoot?.trace_id) { + throw new Error("Expected a traced HarnessAgent root run"); + } + + expect(harnessRoot).toMatchObject({ + run_type: "chain", + outputs: { + role: "assistant", + content: [{ type: "text", text: TASK_FINAL_RESPONSE }], + }, + extra: { + metadata: { + ai_sdk_method: "ai.harness", + integration_test: "ai-sdk-harness-pi", + test_run_id: testRunId, + }, + }, + }); + expect(harnessRoot.extra?.metadata).not.toHaveProperty("ls_provider"); + expect(harnessRoot.extra?.metadata).not.toHaveProperty("usage_metadata"); + expect( + outboundRuns.filter((run) => run.parent_run_id === harnessRoot.id), + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "pi", + run_type: "llm", + extra: expect.objectContaining({ + metadata: expect.objectContaining({ + ls_model_name: "claude-haiku-4-5", + }), + }), + outputs: expect.objectContaining({ + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringContaining(TASK_FINAL_RESPONSE), + }), + ]), + }), + }), + ]), + ); + const outboundToolRun = outboundRuns.find( + (run) => run.name === "bash" && run.run_type === "tool", + ); + if (!outboundToolRun) throw new Error("Expected an outbound Bash tool run"); + expectToolMessageRun(outboundToolRun, harnessRoot.id); + const finalLlmRun = outboundRuns.find( + (run) => + run.parent_run_id === harnessRoot.id && + run.run_type === "llm" && + run.extra?.metadata?.step_number === 1, + ); + if (!finalLlmRun) throw new Error("Expected a completed final Pi LLM run"); + expectCompleteToolHistory(finalLlmRun.inputs); + expect(finalLlmRun.extra?.metadata).not.toHaveProperty("ls_provider"); + expect(finalLlmRun.extra?.metadata?.usage_metadata).toMatchObject({ + input_tokens: expect.any(Number), + output_tokens: expect.any(Number), + total_tokens: expect.any(Number), + }); + expect( + finalLlmRun?.extra?.metadata?.usage_metadata?.total_tokens as number, + ).toBeGreaterThan(0); + + const readPersistedRuns = () => + toArray( + client.listRuns({ + projectName: PROJECT_NAME, + traceId: harnessRoot.trace_id, + }), + ); + let persistedRuns = await readPersistedRuns(); + let persistedFinalLlmRun = await client.readRun(finalLlmRun.id); + await waitUntil( + async () => { + [persistedRuns, persistedFinalLlmRun] = await Promise.all([ + readPersistedRuns(), + client.readRun(finalLlmRun.id), + ]); + return ( + persistedRuns.some( + (run) => + run.id === harnessRoot.id && + run.parent_run_id == null && + JSON.stringify(run.outputs).includes(TASK_FINAL_RESPONSE), + ) && + JSON.stringify(persistedFinalLlmRun.inputs).includes( + '"role":"tool"', + ) && + JSON.stringify(persistedFinalLlmRun.inputs).includes( + TASK_FILE_CONTENT, + ) && + hasCompletedLlmTelemetry(persistedFinalLlmRun) && + persistedRuns.some( + (run) => + run.name === "bash" && + run.run_type === "tool" && + run.parent_run_id === harnessRoot.id && + JSON.stringify(run.inputs).includes(TASK_FILE_NAME) && + !JSON.stringify(run.inputs).includes('"role":"user"') && + !JSON.stringify(run.inputs).includes('"type":"tool-call"') && + JSON.stringify(run.outputs).includes(TASK_FILE_CONTENT), + ) + ); + }, + 30_000, + 500, + "Waiting for the HarnessAgent with Pi trace", + ); + expect( + persistedRuns.filter((run) => run.parent_run_id == null), + ).toMatchObject([ + { + name: ROOT_RUN_NAME, + outputs: { + role: "assistant", + content: [{ type: "text", text: TASK_FINAL_RESPONSE }], + }, + }, + ]); + const persistedRoot = persistedRuns.find( + (run) => run.id === harnessRoot.id, + ); + expect(persistedRoot?.extra?.metadata).not.toHaveProperty("usage_metadata"); + expectCompleteToolHistory(persistedFinalLlmRun.inputs); + expect(persistedFinalLlmRun).toMatchObject({ + parent_run_id: harnessRoot.id, + run_type: "llm", + end_time: expect.anything(), + extra: { + metadata: { + step_number: 1, + ls_model_name: "claude-haiku-4-5", + }, + }, + }); + expect(persistedFinalLlmRun?.extra?.metadata).not.toHaveProperty( + "ls_provider", + ); + expect( + persistedFinalLlmRun?.extra?.metadata?.usage_metadata?.total_tokens, + ).toEqual(expect.any(Number)); + expect( + persistedFinalLlmRun?.extra?.metadata?.usage_metadata + ?.total_tokens as number, + ).toBeGreaterThan(0); + const persistedToolRun = persistedRuns.find( + (run) => run.name === "bash" && run.run_type === "tool", + ); + if (!persistedToolRun) + throw new Error("Expected a persisted Bash tool run"); + expectToolMessageRun(persistedToolRun, harnessRoot.id); + + const traceUrl = await client.getRunUrl({ run: harnessRoot }); + console.log(`\nReal AI SDK HarnessAgent + Pi trace: ${traceUrl}\n`); + } finally { + jest.restoreAllMocks(); + } +}, 60_000); + +const SUBAGENT_ROOT_RUN_NAME = "AI SDK HarnessAgent Pi subagent"; +const COORDINATOR_ROOT_RUN_NAME = "AI SDK HarnessAgent Pi coordinator"; +const DELEGATE_TOOL_NAME = "delegate_to_pi_subagent"; +const SUBAGENT_RESULT = "SUBAGENT_RESULT_42"; +const COORDINATOR_RESULT = "DELEGATION_COMPLETE"; + +test.skip("uploads a nested HarnessAgent and Pi coordinator/subagent trace", async () => { + const testRunId = randomUUID(); + const currentEnv = Reflect.get(process, "env") as NodeJS.ProcessEnv; + const anthropicApiKey = currentEnv.ANTHROPIC_API_KEY; + if (!anthropicApiKey) { + throw new Error("ANTHROPIC_API_KEY is required for this integration test"); + } + + jest.replaceProperty(process, "env", { + ...currentEnv, + TRACE_TO_LANGSMITH: "true", + }); + + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + const piSettings = { + auth: { customEnv: { ANTHROPIC_API_KEY: anthropicApiKey } }, + model: "claude-haiku-4-5", + thinkingLevel: "off" as const, + }; + const coordinatorSessionId = `harness-coordinator-${testRunId}`; + let subagentInvocationCount = 0; + const coordinatorTelemetry = LangSmithTelemetry({ + client, + tracingEnabled: true, + name: COORDINATOR_ROOT_RUN_NAME, + projectName: PROJECT_NAME, + metadata: { + integration_test: "ai-sdk-harness-pi-subagent", + agent_role: "coordinator", + test_run_id: testRunId, + }, + tags: ["integration-test", "ai-sdk-harness", "pi", "coordinator"], + }); + const coordinator = new HarnessAgent({ + id: "pi-coordinator", + harness: createPi(piSettings), + sandbox: createJustBashSandbox({ + env: { WORK_DIR: `/home/user/pi-${coordinatorSessionId}` }, + }), + instructions: [ + `You are a coordinator. You MUST call ${DELEGATE_TOOL_NAME} exactly once.`, + "Do not solve the delegated task yourself and do not use any other tool.", + `After the tool returns, reply with exactly ${COORDINATOR_RESULT}.`, + ].join("\n"), + tools: { + [DELEGATE_TOOL_NAME]: tool({ + description: + "Delegate a self-contained task to an isolated HarnessAgent backed by Pi.", + inputSchema: z.object({ + task: z + .string() + .describe("The complete task for the isolated subagent"), + }), + execute: async ({ task }, { abortSignal, toolCallId }) => { + subagentInvocationCount += 1; + const subagentSessionId = `harness-subagent-${testRunId}-${toolCallId}`; + const subagent = new HarnessAgent({ + id: "pi-subagent", + harness: createPi(piSettings), + sandbox: createJustBashSandbox({ + env: { WORK_DIR: `/home/user/pi-${subagentSessionId}` }, + }), + instructions: [ + "You are an isolated subagent.", + "Complete only the delegated task and do not call tools.", + `Return exactly ${SUBAGENT_RESULT} as your final response.`, + ].join("\n"), + telemetry: { + integrations: [ + LangSmithTelemetry({ + client, + tracingEnabled: true, + name: SUBAGENT_ROOT_RUN_NAME, + projectName: PROJECT_NAME, + metadata: { + integration_test: "ai-sdk-harness-pi-subagent", + agent_role: "subagent", + parent_tool_call_id: toolCallId, + test_run_id: testRunId, + }, + tags: [ + "integration-test", + "ai-sdk-harness", + "pi", + "subagent", + ], + }), + ], + }, + }); + const subagentSession = await subagent.createSession({ + sessionId: subagentSessionId, + abortSignal, + }); + try { + const result = await subagent.generate({ + session: subagentSession, + prompt: task, + abortSignal, + }); + return result.text.trim(); + } finally { + await subagentSession.destroy(); + } + }, + }), + } as any, + telemetry: { integrations: [coordinatorTelemetry] }, + }); + + try { + const coordinatorSession = await coordinator.createSession({ + sessionId: coordinatorSessionId, + }); + try { + const result = await coordinator.generate({ + session: coordinatorSession, + prompt: [ + `Call ${DELEGATE_TOOL_NAME} with a task that asks the isolated subagent`, + `to return exactly ${SUBAGENT_RESULT}.`, + `Then reply with exactly ${COORDINATOR_RESULT}.`, + ].join(" "), + }); + expect(result.text).toEqual(expect.any(String)); + expect(result.toolResults).toEqual(expect.any(Array)); + expect(subagentInvocationCount).toBe(1); + } finally { + await coordinatorSession.destroy(); + } + + await client.awaitPendingTraceBatches(); + await waitUntil( + async () => { + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const runs = Object.values(tree.data); + return ( + runs.some( + (run) => + run.name === COORDINATOR_ROOT_RUN_NAME && + run.run_type === "chain", + ) && + runs.some( + (run) => + run.name === SUBAGENT_ROOT_RUN_NAME && run.run_type === "chain", + ) && + runs.some( + (run) => run.name === DELEGATE_TOOL_NAME && run.run_type === "tool", + ) && + runs.some( + (run) => + run.run_type === "llm" && + run.extra?.metadata?.step_number === 1 && + hasNonEmptyTextOutput(run.outputs) && + typeof run.extra?.metadata?.usage_metadata?.total_tokens === + "number", + ) + ); + }, + 10_000, + 100, + "Waiting for coordinator and subagent telemetry writes", + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const outboundRuns = Object.values(tree.data); + const coordinatorRoot = outboundRuns.find( + (run) => + run.name === COORDINATOR_ROOT_RUN_NAME && run.parent_run_id == null, + ); + if (!coordinatorRoot?.trace_id) { + throw new Error("Expected a traced coordinator root run"); + } + + expect(coordinatorRoot).toMatchObject({ + run_type: "chain", + outputs: expect.anything(), + extra: { + metadata: { + ai_sdk_method: "ai.harness", + agent_role: "coordinator", + test_run_id: testRunId, + }, + }, + }); + expect(coordinatorRoot.extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + const delegateRun = outboundRuns.find( + (run) => + run.name === DELEGATE_TOOL_NAME && + run.parent_run_id === coordinatorRoot.id, + ); + if (!delegateRun) throw new Error("Expected a delegate tool run"); + expect(delegateRun).toMatchObject({ + run_type: "tool", + inputs: { task: expect.any(String) }, + outputs: { output: expect.any(String) }, + }); + const subagentRoot = outboundRuns.find( + (run) => + run.name === SUBAGENT_ROOT_RUN_NAME && + run.parent_run_id === delegateRun.id, + ); + if (!subagentRoot?.trace_id) { + throw new Error("Expected the subagent run beneath the delegate tool"); + } + const delegateToolCallId = ( + subagentRoot.extra as { + metadata?: { parent_tool_call_id?: unknown }; + } + ).metadata?.parent_tool_call_id; + if (typeof delegateToolCallId !== "string") { + throw new Error("Expected the subagent tool-call metadata"); + } + + expect(subagentRoot).toMatchObject({ + run_type: "chain", + outputs: expect.anything(), + extra: { + metadata: { + ai_sdk_method: "ai.harness", + agent_role: "subagent", + parent_tool_call_id: delegateToolCallId, + test_run_id: testRunId, + }, + }, + }); + expect(subagentRoot.parent_run_id).toBe(delegateRun.id); + expect(subagentRoot.trace_id).toBe(coordinatorRoot.trace_id); + expect(subagentRoot.extra?.metadata).not.toHaveProperty("usage_metadata"); + + const subagentLlm = outboundRuns.find( + (run) => run.parent_run_id === subagentRoot.id && run.run_type === "llm", + ); + if (!subagentLlm) throw new Error("Expected a completed subagent LLM run"); + const subagentUsage = subagentLlm.extra?.metadata?.usage_metadata as + | { total_tokens?: unknown } + | undefined; + expect(subagentUsage).toMatchObject({ + input_tokens: expect.any(Number), + output_tokens: expect.any(Number), + total_tokens: expect.any(Number), + }); + expect(subagentUsage?.total_tokens).toEqual(expect.any(Number)); + expect(subagentUsage?.total_tokens as number).toBeGreaterThan(0); + + const finalCoordinatorLlm = outboundRuns.find( + (run) => + run.parent_run_id === coordinatorRoot.id && + run.run_type === "llm" && + run.extra?.metadata?.step_number === 1, + ); + if (!finalCoordinatorLlm) { + throw new Error("Expected a completed final coordinator LLM run"); + } + expect(finalCoordinatorLlm).toMatchObject({ + end_time: expect.anything(), + extra: { + metadata: { + usage_metadata: { + input_tokens: expect.any(Number), + output_tokens: expect.any(Number), + total_tokens: expect.any(Number), + }, + }, + }, + }); + expectNonEmptyTextOutput(finalCoordinatorLlm.outputs); + expect( + finalCoordinatorLlm.extra?.metadata?.usage_metadata + ?.total_tokens as number, + ).toBeGreaterThan(0); + + const coordinatorTraceId = coordinatorRoot.trace_id; + const readTrace = () => + toArray( + client.listRuns({ + projectName: PROJECT_NAME, + traceId: coordinatorTraceId, + }), + ); + let persistedRuns = await readTrace(); + let persistedCoordinatorLlm = await client.readRun(finalCoordinatorLlm.id); + let persistedSubagentLlm = await client.readRun(subagentLlm.id); + await waitUntil( + async () => { + [persistedRuns, persistedCoordinatorLlm, persistedSubagentLlm] = + await Promise.all([ + readTrace(), + client.readRun(finalCoordinatorLlm.id), + client.readRun(subagentLlm.id), + ]); + return ( + persistedRuns.some((run) => run.id === coordinatorRoot.id) && + persistedRuns.some( + (run) => + run.id === delegateRun.id && + run.parent_run_id === coordinatorRoot.id, + ) && + persistedRuns.some( + (run) => + run.id === subagentRoot.id && + run.parent_run_id === delegateRun.id && + run.trace_id === coordinatorTraceId, + ) && + hasCompletedLlmTelemetry(persistedCoordinatorLlm) && + hasCompletedLlmTelemetry(persistedSubagentLlm) + ); + }, + 30_000, + 500, + "Waiting for the persisted nested coordinator/subagent trace", + ); + + const persistedCoordinatorRoot = persistedRuns.find( + (run) => run.id === coordinatorRoot.id, + ); + const persistedSubagentRoot = persistedRuns.find( + (run) => run.id === subagentRoot.id, + ); + expect(persistedCoordinatorRoot?.extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect(persistedSubagentRoot?.extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect(persistedCoordinatorLlm).toMatchObject({ + parent_run_id: coordinatorRoot.id, + run_type: "llm", + end_time: expect.anything(), + extra: { + metadata: { + usage_metadata: { + input_tokens: expect.any(Number), + output_tokens: expect.any(Number), + total_tokens: expect.any(Number), + }, + }, + }, + }); + expectNonEmptyTextOutput(persistedCoordinatorLlm.outputs); + expect( + persistedCoordinatorLlm.extra?.metadata?.usage_metadata + ?.total_tokens as number, + ).toBeGreaterThan(0); + expect(persistedSubagentLlm).toMatchObject({ + parent_run_id: subagentRoot.id, + run_type: "llm", + end_time: expect.anything(), + }); + expectNonEmptyTextOutput(persistedSubagentLlm.outputs); + expect( + persistedSubagentLlm.extra?.metadata?.usage_metadata?.total_tokens, + ).toEqual(expect.any(Number)); + expect( + persistedSubagentLlm?.extra?.metadata?.usage_metadata + ?.total_tokens as number, + ).toBeGreaterThan(0); + + const traceUrl = await client.getRunUrl({ run: coordinatorRoot }); + console.log(`\nReal nested Pi coordinator/subagent trace: ${traceUrl}\n`); + } finally { + jest.restoreAllMocks(); + } +}, 120_000); diff --git a/src/lib/tests/vercel/telemetry/harness-claude-code.int.test.ts b/src/lib/tests/vercel/telemetry/harness-claude-code.int.test.ts new file mode 100644 index 0000000..752d5c0 --- /dev/null +++ b/src/lib/tests/vercel/telemetry/harness-claude-code.int.test.ts @@ -0,0 +1,223 @@ +import { HarnessAgent } from "@ai-sdk/harness/agent"; +import { createClaudeCode } from "@ai-sdk/harness-claude-code"; +import { createVercelSandbox } from "@ai-sdk/sandbox-vercel"; +import { jest } from "@jest/globals"; +import { randomUUID } from "node:crypto"; +import { Client } from "../../../client.js"; +import { LangSmithTelemetry } from "../../../experimental/vercel/telemetry.js"; +import { toArray, waitUntil } from "../../utils.js"; +import { getAssumedTreeFromCalls } from "../../utils/tree.js"; + +const PROJECT_NAME = "js-ai-sdk-harness-claude-code-integration"; +const ROOT_RUN_NAME = "AI SDK HarnessAgent with Claude Code"; +const BRIDGE_PORT = 4000; + +function hasNonEmptyTextOutput(outputs: unknown): boolean { + if (outputs == null || typeof outputs !== "object") return false; + const content = (outputs as { content?: unknown }).content; + return ( + Array.isArray(content) && + content.some( + (part) => + part != null && + typeof part === "object" && + (part as { type?: unknown }).type === "text" && + typeof (part as { text?: unknown }).text === "string" && + (part as { text: string }).text.trim().length > 0, + ) + ); +} + +function expectCompletedLlmRun(run: { + outputs?: unknown; + end_time?: string | number; + extra?: { metadata?: Record }; +}) { + expect(run).toMatchObject({ + end_time: expect.anything(), + extra: { + metadata: { + usage_metadata: { + input_tokens: expect.any(Number), + output_tokens: expect.any(Number), + total_tokens: expect.any(Number), + }, + }, + }, + }); + expect(hasNonEmptyTextOutput(run.outputs)).toBe(true); + expect( + run.extra?.metadata?.usage_metadata?.total_tokens as number, + ).toBeGreaterThan(0); +} + +// Requires a Vercel key +test.skip( + "uploads a real HarnessAgent and Claude Code trace", + async () => { + const testRunId = randomUUID(); + const currentEnv = Reflect.get(process, "env") as NodeJS.ProcessEnv; + const anthropicApiKey = currentEnv.ANTHROPIC_API_KEY; + if (!anthropicApiKey) { + throw new Error( + "ANTHROPIC_API_KEY is required for this integration test", + ); + } + + jest.replaceProperty(process, "env", { + ...currentEnv, + TRACE_TO_LANGSMITH: "true", + }); + + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + const agent = new HarnessAgent({ + harness: createClaudeCode({ + auth: { anthropic: { apiKey: anthropicApiKey } }, + model: "claude-haiku-4-5", + maxTurns: 1, + port: BRIDGE_PORT, + }), + sandbox: createVercelSandbox({ + runtime: "node24", + ports: [BRIDGE_PORT], + timeout: 10 * 60 * 1000, + }), + telemetry: { + integrations: [ + LangSmithTelemetry({ + client, + tracingEnabled: true, + name: ROOT_RUN_NAME, + projectName: PROJECT_NAME, + metadata: { + integration_test: "ai-sdk-harness-claude-code", + test_run_id: testRunId, + }, + tags: ["integration-test", "ai-sdk-harness", "claude-code"], + }), + ], + }, + }); + + try { + const session = await agent.createSession({ + sessionId: `harness-claude-code-${testRunId}`, + }); + try { + const result = await agent.generate({ + session, + prompt: "Reply with one short sentence about observability.", + }); + expect(result.text.trim().length).toBeGreaterThan(0); + } finally { + await session.destroy(); + } + + await client.awaitPendingTraceBatches(); + await waitUntil( + async () => { + const tree = await getAssumedTreeFromCalls( + callSpy.mock.calls, + client, + ); + const runs = Object.values(tree.data); + const root = runs.find( + (run) => run.name === ROOT_RUN_NAME && run.parent_run_id == null, + ); + return ( + root != null && + runs.some( + (run) => + run.parent_run_id === root.id && + run.run_type === "llm" && + run.end_time != null && + hasNonEmptyTextOutput(run.outputs) && + typeof run.extra?.metadata?.usage_metadata?.total_tokens === + "number", + ) + ); + }, + 30_000, + 250, + "Waiting for completed Claude Code telemetry writes", + ); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + const outboundRuns = Object.values(tree.data); + const root = outboundRuns.find( + (run) => run.name === ROOT_RUN_NAME && run.parent_run_id == null, + ); + if (!root?.trace_id) { + throw new Error("Expected a traced Claude Code root run"); + } + expect(root).toMatchObject({ + run_type: "chain", + end_time: expect.anything(), + extra: { + metadata: { + ai_sdk_method: "ai.harness", + integration_test: "ai-sdk-harness-claude-code", + test_run_id: testRunId, + }, + }, + }); + expect(root.extra?.metadata).not.toHaveProperty("usage_metadata"); + + const llmRuns = outboundRuns.filter( + (run) => run.parent_run_id === root.id && run.run_type === "llm", + ); + expect(llmRuns).toHaveLength(1); + const [llmRun] = llmRuns; + expectCompletedLlmRun(llmRun); + + const readTrace = () => + toArray( + client.listRuns({ + projectName: PROJECT_NAME, + traceId: root.trace_id, + }), + ); + let persistedRuns = await readTrace(); + await waitUntil( + async () => { + persistedRuns = await readTrace(); + const persistedLlm = persistedRuns.find( + (run) => run.id === llmRun.id, + ); + return ( + persistedLlm != null && + persistedLlm.end_time != null && + hasNonEmptyTextOutput(persistedLlm.outputs) && + typeof persistedLlm.extra?.metadata?.usage_metadata + ?.total_tokens === "number" + ); + }, + 30_000, + 500, + "Waiting for the persisted Claude Code trace", + ); + + const persistedRoot = persistedRuns.find((run) => run.id === root.id); + const persistedLlm = persistedRuns.find((run) => run.id === llmRun.id); + expect(persistedRoot).toMatchObject({ end_time: expect.anything() }); + expect(persistedRoot?.extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + if (!persistedLlm) + throw new Error("Expected a persisted Claude Code LLM run"); + expectCompletedLlmRun(persistedLlm); + + const traceUrl = await client.getRunUrl({ run: root }); + console.log( + `\nReal AI SDK HarnessAgent + Claude Code trace: ${traceUrl}\n`, + ); + } finally { + jest.restoreAllMocks(); + } + }, + 15 * 60_000, +); diff --git a/src/lib/tests/vercel/telemetry/methods.vitesttest.ts b/src/lib/tests/vercel/telemetry/methods.vitesttest.ts new file mode 100644 index 0000000..03703fd --- /dev/null +++ b/src/lib/tests/vercel/telemetry/methods.vitesttest.ts @@ -0,0 +1,1783 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + generateText, + isStepCount, + registerTelemetry, + simulateReadableStream, + streamText, + tool, +} from "ai"; +import { MockLanguageModelV4 } from "ai/test"; +import { z } from "zod"; +import { describe, it, expect, vi } from "vitest"; +import { LangSmithTelemetry } from "../../../experimental/vercel/telemetry.js"; +import { _processingFailed } from "../../../singletons/constants.js"; +import { traceable } from "../../../traceable.js"; +import { Client } from "../../../index.js"; +import { getAssumedTreeFromCalls } from "../../utils/tree.js"; +import type { + LanguageModelV4, + LanguageModelV4GenerateResult, + LanguageModelV4Usage, +} from "@ai-sdk/provider"; + +function usage({ + input, + output, + cacheRead = 0, + cacheWrite = 0, + reasoning = 0, +}: { + input: number; + output: number; + cacheRead?: number; + cacheWrite?: number; + reasoning?: number; +}): LanguageModelV4Usage { + return { + inputTokens: { + total: input, + noCache: input - cacheRead - cacheWrite, + cacheRead, + cacheWrite, + }, + outputTokens: { + total: output, + text: output - reasoning, + reasoning, + }, + }; +} + +function textResult( + text: string, + tokenUsage = usage({ input: 5, output: 3 }), + finishReason = "stop", +): LanguageModelV4GenerateResult { + return { + content: [{ type: "text" as const, text }], + finishReason: { unified: finishReason as "stop", raw: finishReason }, + usage: tokenUsage, + warnings: [], + }; +} + +function toolCallResult( + { + toolCallId, + toolName, + input, + }: { toolCallId: string; toolName: string; input: Record }, + tokenUsage = usage({ input: 10, output: 5 }), +): LanguageModelV4GenerateResult { + return { + content: [ + { + type: "tool-call" as const, + toolCallId, + toolName, + input: JSON.stringify(input), + }, + ], + finishReason: { unified: "tool-calls" as const, raw: "tool-calls" }, + usage: tokenUsage, + warnings: [], + }; +} + +function createModel({ + provider = "test-provider", + modelId = "test-model", + responses = [textResult("Hello world")], + doGenerate, +}: { + provider?: string; + modelId?: string; + responses?: LanguageModelV4GenerateResult[]; + doGenerate?: LanguageModelV4["doGenerate"]; +} = {}) { + return new MockLanguageModelV4({ + provider, + modelId, + doGenerate: doGenerate ?? responses, + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: usage({ input: 5, output: 3 }), + }, + ], + }), + }), + }); +} + +function createTrace(config?: Parameters[0]) { + const callSpy = vi.fn( + async () => + new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const integration = LangSmithTelemetry({ + tracingEnabled: true, + client, + ...config, + }); + return { callSpy, client, integration }; +} + +async function expectTree({ + callSpy, + client, +}: { + callSpy: ReturnType; + client: Client; +}) { + return getAssumedTreeFromCalls(callSpy.mock.calls, client); +} + +describe("basic tracing", () => { + it("should create root and step spans for a simple generateText", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + textResult("Test response", usage({ input: 5, output: 3 })), + ], + }), + prompt: "Test prompt", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + run_type: "chain", + inputs: { messages: [{ role: "user", content: "Test prompt" }] }, + extra: { metadata: { ls_integration: "vercel-ai-sdk-telemetry" } }, + outputs: { role: "assistant", content: "Test response" }, + }, + "test-provider:1": { + run_type: "llm", + inputs: { messages: [{ role: "user", content: "Test prompt" }] }, + outputs: { + role: "assistant", + content: [{ type: "text", text: "Test response" }], + }, + }, + }, + }); + }); + + it("uses only the final step when the root event has empty content", async () => { + const trace = createTrace(); + const callId = "harness-call"; + const messages = [{ role: "user", content: "Complete a task" }]; + + await trace.integration.onStart({ + callId, + operationId: "ai.harness", + provider: "pi", + modelId: "claude-haiku-4-5", + messages, + }); + await trace.integration.onStepStart({ + callId, + provider: "pi", + modelId: "claude-haiku-4-5", + stepNumber: 0, + messages, + }); + await trace.integration.onToolExecutionStart({ + callId, + toolCall: { + type: "tool-call", + toolCallId: "tool-1", + toolName: "bash", + input: '{"command":"echo done"}', + }, + }); + await trace.integration.onToolExecutionEnd({ + callId, + toolExecutionMs: 1, + toolCall: { + type: "tool-call", + toolCallId: "tool-1", + toolName: "bash", + input: '{"command":"echo done"}', + }, + toolOutput: { type: "tool-result", output: "done" }, + }); + await trace.integration.onStepFinish({ + callId, + stepNumber: 0, + content: [ + { type: "text", text: "I will use a tool." }, + { + type: "tool-call", + toolCallId: "tool-1", + toolName: "bash", + input: "{}", + }, + ], + finishReason: { unified: "tool-calls" }, + usage: usage({ input: 0, output: 0 }), + }); + await trace.integration.onStepStart({ + callId, + provider: "pi", + modelId: "claude-haiku-4-5", + stepNumber: 1, + messages, + }); + await trace.integration.onStepFinish({ + callId, + stepNumber: 1, + content: [{ type: "text", text: "TASK_COMPLETE" }], + finishReason: { unified: "stop" }, + usage: usage({ input: 0, output: 0 }), + }); + await trace.integration.onEnd({ + callId, + operationId: "ai.harness", + content: [], + finishReason: { unified: "stop" }, + usage: usage({ input: 10, output: 4 }), + totalUsage: usage({ input: 10, output: 4 }), + }); + + const tree = await expectTree(trace); + expect(tree.data["pi:0"].extra?.metadata).not.toHaveProperty("ls_provider"); + expect(tree.data["pi:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect(tree.data["pi:1"].extra?.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + }); + expect(tree.data["pi:1"].extra?.metadata).not.toHaveProperty("ls_provider"); + expect(tree.data["pi:3"].extra?.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + usage_metadata: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14, + }, + }); + expect(tree.data["pi:3"].extra?.metadata).not.toHaveProperty("ls_provider"); + expect(tree.data["pi:0"].outputs).toEqual({ + role: "assistant", + content: [{ type: "text", text: "TASK_COMPLETE" }], + finish_reason: { unified: "stop" }, + }); + expect(JSON.stringify(tree.data["pi:0"].outputs)).not.toContain( + "I will use a tool.", + ); + expect(tree.edges).toContainEqual(["pi:0", "bash:2"]); + expect(tree.data["bash:2"]).toMatchObject({ + run_type: "tool", + inputs: { command: "echo done" }, + outputs: { output: "done" }, + }); + expect(tree.data["pi:3"].inputs).toMatchObject({ + messages: [ + { role: "user", content: "Complete a task" }, + { + role: "assistant", + tool_calls: [ + expect.objectContaining({ + id: "tool-1", + function: expect.objectContaining({ name: "bash" }), + }), + ], + }, + { + role: "tool", + content: "done", + tool_call_id: "tool-1", + name: "bash", + artifact: "done", + }, + ], + }); + }); + + it("should use the provider as default run name and store the AI SDK operation id", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ provider: "openai", modelId: "gpt-4o" }), + prompt: "Hello", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree).toMatchObject({ + edges: [["openai:0", "openai:1"]], + data: { + "openai:0": { + name: "openai", + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_model_name: "gpt-4o", + }, + }, + }, + "openai:1": { + extra: { metadata: { ls_model_name: "gpt-4o" } }, + }, + }, + }); + expect(tree.data["openai:1"].extra?.metadata).not.toHaveProperty( + "ls_provider", + ); + }); + + it("should use the AI SDK function id as the default run name when present", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel(), + prompt: "Hello", + telemetry: { + integrations: [trace.integration], + functionId: "summarize-endpoint", + }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["summarize-endpoint:0", "test-provider:1"]], + data: { + "summarize-endpoint:0": { + name: "summarize-endpoint", + run_type: "chain", + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_model_name: "test-model", + }, + }, + }, + "test-provider:1": { + extra: { metadata: { ls_model_name: "test-model" } }, + }, + }, + }); + }); + + it("should allow custom name override", async () => { + const trace = createTrace({ name: "my-agent" }); + await generateText({ + model: createModel(), + prompt: "Hello", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["my-agent:0", "test-provider:1"]], + data: { "my-agent:0": { name: "my-agent", run_type: "chain" } }, + }); + }); + + it("should apply custom metadata and tags", async () => { + const trace = createTrace({ + metadata: { customField: "test-value", version: "2.0" }, + tags: ["test-tag", "v2"], + }); + + await generateText({ + model: createModel(), + prompt: "Test with metadata", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { + tags: ["test-tag", "v2"], + extra: { + metadata: { + customField: "test-value", + version: "2.0", + ls_integration: "vercel-ai-sdk-telemetry", + }, + }, + }, + }, + }); + }); + + it("should not record root, step, or tool inputs when recordInputs is false", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-sensitive-1", + toolName: "sensitiveTool", + input: { secret: "tool-secret" }, + }), + textResult("Safe response", usage({ input: 12, output: 4 })), + ], + }), + prompt: "Sensitive prompt", + tools: { + sensitiveTool: tool({ + inputSchema: z.object({ secret: z.string() }), + execute: async () => "tool result", + }), + }, + stopWhen: isStepCount(10), + telemetry: { + integrations: [trace.integration], + recordInputs: false, + }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:0"].inputs).toEqual({}); + expect(tree.data["test-provider:1"].inputs).toEqual({}); + expect(tree.data["sensitiveTool:2"].inputs).toEqual({}); + expect(tree.data["test-provider:3"].inputs).toEqual({}); + }); + + it("should not record root, step, or tool outputs when recordOutputs is false", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-sensitive-1", + toolName: "sensitiveTool", + input: { query: "safe input" }, + }), + textResult( + "Sensitive final response", + usage({ input: 12, output: 4 }), + ), + ], + }), + prompt: "Use a tool", + tools: { + sensitiveTool: tool({ + inputSchema: z.object({ query: z.string() }), + execute: async () => "sensitive tool output", + }), + }, + stopWhen: isStepCount(10), + telemetry: { + integrations: [trace.integration], + recordOutputs: false, + }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:0"].outputs ?? {}).toEqual({}); + expect(tree.data["test-provider:1"].outputs ?? {}).toEqual({}); + expect(tree.data["sensitiveTool:2"].outputs ?? {}).toEqual({}); + expect(tree.data["test-provider:3"].outputs ?? {}).toEqual({}); + expect(tree.data["test-provider:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect(tree.data["test-provider:3"].extra?.metadata).toMatchObject({ + usage_metadata: { + input_tokens: 12, + output_tokens: 4, + total_tokens: 16, + }, + }); + }); +}); + +describe("metadata", () => { + it("should set ai_sdk_method and root ls_agent_type on top-level runs", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel(), + prompt: "Metadata test", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + const rootRun = Object.values(tree.data).find( + (run) => + run.run_type === "chain" && + run.extra?.metadata?.ai_sdk_method === "ai.generateText", + ); + + expect(rootRun?.extra?.metadata).toMatchObject({ + ai_sdk_method: "ai.generateText", + ls_agent_type: "root", + }); + }); + + it("should set ls_agent_type to subagent for telemetry runs inside tools", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-research-1", + toolName: "research", + input: { topic: "AI" }, + }), + textResult("Research complete.", usage({ input: 20, output: 8 })), + ], + }), + prompt: "Use a tool with sub-agent", + tools: { + research: tool({ + inputSchema: z.object({ topic: z.string() }), + execute: async () => { + const result = await generateText({ + model: createModel({ + provider: "inner-provider", + modelId: "inner-model", + responses: [textResult("Inner result")], + }), + prompt: "Inner prompt", + telemetry: { integrations: [trace.integration] }, + }); + return result.text; + }, + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "research:2"], + ["research:2", "inner-provider:3"], + ["inner-provider:3", "inner-provider:4"], + ["test-provider:0", "test-provider:5"], + ], + data: { + "test-provider:0": { + run_type: "chain", + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_agent_type: "root", + }, + }, + }, + "test-provider:1": { + run_type: "llm", + extra: { metadata: { ls_agent_type: "root" } }, + }, + "research:2": { + run_type: "tool", + extra: { metadata: { ls_agent_type: "root" } }, + }, + "inner-provider:3": { + run_type: "chain", + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_agent_type: "subagent", + }, + }, + }, + "inner-provider:4": { + run_type: "llm", + extra: { metadata: { ls_agent_type: "subagent" } }, + }, + "test-provider:5": { + run_type: "llm", + extra: { metadata: { ls_agent_type: "root" } }, + }, + }, + }); + }); +}); + +describe("global telemetry registration", () => { + it("should trace globally registered telemetry and allow local child name override", async () => { + const trace = createTrace(); + const previousGlobalIntegrations = globalThis.AI_SDK_TELEMETRY_INTEGRATIONS; + + try { + registerTelemetry(trace.integration); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-delegate-1", + toolName: "delegate", + input: { task: "summarize" }, + }), + textResult("Delegation complete.", usage({ input: 20, output: 8 })), + ], + }), + prompt: "Delegate this task", + tools: { + delegate: tool({ + inputSchema: z.object({ task: z.string() }), + execute: async () => { + const result = await generateText({ + model: createModel({ + provider: "child-provider", + modelId: "child-model", + responses: [textResult("Child answer")], + }), + prompt: "Child prompt", + telemetry: { + integrations: [ + LangSmithTelemetry({ + client: trace.client, + name: "child-agent", + tracingEnabled: true, + }), + ], + }, + }); + return result.text; + }, + }), + }, + stopWhen: isStepCount(10), + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "delegate:2"], + ["delegate:2", "child-agent:3"], + ["child-agent:3", "child-provider:4"], + ["test-provider:0", "test-provider:5"], + ], + data: { + "test-provider:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: "Delegate this task" }], + tools: ["delegate"], + }, + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_agent_type: "root", + }, + }, + }, + "delegate:2": { + run_type: "tool", + inputs: { task: "summarize" }, + outputs: { output: "Child answer" }, + }, + "child-agent:3": { + name: "child-agent", + run_type: "chain", + inputs: { + messages: [{ role: "user", content: "Child prompt" }], + }, + outputs: { content: "Child answer" }, + extra: { + metadata: { + ai_sdk_method: "ai.generateText", + ls_agent_type: "subagent", + }, + }, + }, + "child-provider:4": { + run_type: "llm", + extra: { metadata: { ls_agent_type: "subagent" } }, + }, + }, + }); + } finally { + globalThis.AI_SDK_TELEMETRY_INTEGRATIONS = previousGlobalIntegrations; + } + }); +}); + +describe("error handling", () => { + it("should capture errors on the root span", async () => { + const trace = createTrace(); + await expect( + generateText({ + model: createModel({ + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK ERROR"); + }, + }), + prompt: "This should fail", + telemetry: { integrations: [trace.integration] }, + }), + ).rejects.toThrow("TOTALLY EXPECTED MOCK ERROR"); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + error: expect.stringContaining("TOTALLY EXPECTED MOCK ERROR"), + }, + }, + }); + }); + + it("should close open step runs on error", async () => { + const trace = createTrace(); + + await expect( + generateText({ + model: createModel({ + doGenerate: async () => { + throw new Error("Mid-step error"); + }, + }), + prompt: "Test", + telemetry: { integrations: [trace.integration] }, + }), + ).rejects.toThrow("Mid-step error"); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + error: expect.stringContaining("Mid-step error"), + }, + "test-provider:1": { + error: expect.stringContaining("Mid-step error"), + }, + }, + }); + }); +}); + +describe("multi-step tracing", () => { + it("should create separate LLM spans for each step", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult( + { + toolCallId: "tc-1", + toolName: "search", + input: { query: "weather" }, + }, + usage({ input: 10, output: 5 }), + ), + textResult( + "The weather is sunny and 72F.", + usage({ input: 20, output: 8 }), + ), + ], + }), + prompt: "Multi-step test", + tools: { + search: tool({ + inputSchema: z.object({ query: z.string() }), + execute: async () => "Sunny, 72F", + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree).toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "search:2"], + ["test-provider:0", "test-provider:3"], + ], + data: { + "test-provider:0": { + inputs: { + messages: [{ role: "user", content: "Multi-step test" }], + tools: ["search"], + }, + }, + "test-provider:1": { + run_type: "llm", + extra: { + metadata: { step_number: 0 }, + invocation_params: { + tools: [ + expect.objectContaining({ + name: "search", + input_schema: expect.objectContaining({ + type: "object", + properties: { + query: expect.objectContaining({ type: "string" }), + }, + }), + }), + ], + }, + }, + outputs: { + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "search", + input: { query: "weather" }, + }), + ]), + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "search" }), + }), + ]), + }, + }, + "search:2": { + run_type: "tool", + inputs: { query: "weather" }, + outputs: { output: "Sunny, 72F" }, + }, + "test-provider:3": { + run_type: "llm", + extra: { metadata: { step_number: 1 } }, + outputs: { + content: [{ type: "text", text: "The weather is sunny and 72F." }], + }, + }, + }, + }); + + expect( + tree.data["test-provider:1"].extra?.invocation_params?.tools?.[0], + ).not.toHaveProperty("inputSchema"); + }); +}); + +describe("tool tracing via executeTool", () => { + it("should create tool spans and record outputs on onToolExecutionEnd", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-calc-1", + toolName: "calculator", + input: { expression: "2+2" }, + }), + textResult("2+2 = 4", usage({ input: 15, output: 5 })), + ], + }), + prompt: "Use a tool", + tools: { + calculator: tool({ + inputSchema: z.object({ expression: z.string() }), + execute: async () => 4, + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "calculator:2"], + ["test-provider:0", "test-provider:3"], + ], + data: { + "calculator:2": { + run_type: "tool", + inputs: { expression: "2+2" }, + outputs: { output: 4 }, + }, + }, + }); + }); + + it("should nest sub-agent calls inside tool spans", async () => { + const trace = createTrace(); + const innerTraceable = traceable( + async (input: string) => `Processed: ${input}`, + { + name: "inner-agent-call", + run_type: "chain", + client: trace.client, + tracingEnabled: true, + }, + ); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-research-1", + toolName: "research", + input: { topic: "AI" }, + }), + textResult("Research complete.", usage({ input: 20, output: 8 })), + ], + }), + prompt: "Use a tool with sub-agent", + tools: { + research: tool({ + inputSchema: z.object({ topic: z.string() }), + execute: async () => innerTraceable("AI research"), + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "research:2"], + ["research:2", "inner-agent-call:3"], + ["test-provider:0", "test-provider:4"], + ], + data: { + "research:2": { + run_type: "tool", + inputs: { topic: "AI" }, + outputs: { output: "Processed: AI research" }, + }, + "inner-agent-call:3": { + run_type: "chain", + inputs: { input: "AI research" }, + outputs: { outputs: "Processed: AI research" }, + }, + }, + }); + }); +}); + +describe("usage metadata tracking", () => { + it("should track AI SDK 6 token usage on step runs", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + textResult( + "Response", + usage({ + input: 100, + output: 25, + cacheRead: 30, + cacheWrite: 20, + reasoning: 10, + }), + ), + ], + }), + prompt: "Token test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:1": { + extra: { + metadata: { + usage_metadata: { + input_tokens: 100, + output_tokens: 25, + total_tokens: 125, + input_token_details: { + cache_read: 30, + cache_creation: 20, + }, + output_token_details: { reasoning: 10 }, + }, + }, + }, + }, + }, + }); + }); + + it("does not assign aggregate Harness usage to non-Pi steps", async () => { + const trace = createTrace(); + const callId = "non-pi-harness-call"; + + await trace.integration.onStart({ + callId, + operationId: "ai.harness", + provider: "other-harness", + modelId: "test-model", + messages: [{ role: "user", content: "Test" }], + }); + await trace.integration.onStepStart({ + callId, + provider: "other-harness", + modelId: "test-model", + stepNumber: 0, + messages: [{ role: "user", content: "Test" }], + }); + await trace.integration.onStepFinish({ + callId, + stepNumber: 0, + content: [{ type: "text", text: "Done" }], + finishReason: { unified: "stop" }, + usage: usage({ input: 0, output: 0 }), + }); + await trace.integration.onEnd({ + callId, + operationId: "ai.harness", + content: [], + finishReason: { unified: "stop" }, + usage: usage({ input: 10, output: 5 }), + totalUsage: usage({ input: 10, output: 5 }), + }); + + const tree = await expectTree(trace); + expect( + tree.data["other-harness:1"].extra?.metadata?.usage_metadata, + ).toMatchObject({ + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }); + }); + + it("should track usage only on LLM spans", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + textResult("Step response", usage({ input: 30, output: 15 })), + ], + }), + prompt: "Aggregate test", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect(tree.data["test-provider:1"].extra?.metadata).toMatchObject({ + usage_metadata: { + input_tokens: 30, + output_tokens: 15, + total_tokens: 45, + }, + }); + }); +}); + +describe("invocation params", () => { + it("should record language model call params on step spans", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel(), + prompt: "Call settings test", + maxOutputTokens: 25, + temperature: 0.2, + topP: 0.9, + seed: 42, + telemetry: { + integrations: [trace.integration], + functionId: "settings-endpoint", + recordInputs: false, + recordOutputs: false, + }, + }); + + const tree = await expectTree(trace); + expect(tree).toMatchObject({ + edges: [["settings-endpoint:0", "test-provider:1"]], + data: { + "test-provider:1": { + run_type: "llm", + extra: { + invocation_params: { + callId: expect.any(String), + functionId: "settings-endpoint", + maxOutputTokens: 25, + temperature: 0.2, + topP: 0.9, + seed: 42, + }, + }, + }, + }, + }); + + const invocationParams = + tree.data["test-provider:1"].extra?.invocation_params; + expect(invocationParams).not.toHaveProperty("messages"); + expect(invocationParams).not.toHaveProperty("provider"); + expect(invocationParams).not.toHaveProperty("modelId"); + expect(invocationParams).not.toHaveProperty("recordInputs"); + expect(invocationParams).not.toHaveProperty("recordOutputs"); + }); +}); + +describe("runtime and tool context", () => { + it("should trace telemetry-filtered runtime and tool context", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-weather-1", + toolName: "weather", + input: { location: "Prague" }, + }), + textResult("It is 21 degrees.", usage({ input: 20, output: 6 })), + ], + }), + prompt: "Check the weather", + runtimeContext: { + requestId: "req-123", + userId: "user-secret", + }, + tools: { + weather: tool({ + inputSchema: z.object({ location: z.string() }), + contextSchema: z.object({ + defaultUnit: z.enum(["celsius", "fahrenheit"]), + apiKey: z.string(), + }), + execute: async ({ location }, { context }) => ({ + location, + unit: context.defaultUnit, + }), + }), + }, + toolsContext: { + weather: { + defaultUnit: "celsius", + apiKey: "weather-secret", + }, + }, + stopWhen: isStepCount(10), + telemetry: { + integrations: [trace.integration], + includeRuntimeContext: { + requestId: true, + }, + includeToolsContext: { + weather: { + defaultUnit: true, + }, + }, + }, + }); + + const tree = await expectTree(trace); + expect(tree).toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "weather:2"], + ["test-provider:0", "test-provider:3"], + ], + data: { + "test-provider:0": { + inputs: { + runtimeContext: { requestId: "req-123" }, + toolsContext: { + weather: { defaultUnit: "celsius" }, + }, + }, + }, + "test-provider:1": { + inputs: { + runtimeContext: { requestId: "req-123" }, + toolsContext: { + weather: { defaultUnit: "celsius" }, + }, + }, + }, + "weather:2": { + run_type: "tool", + inputs: { location: "Prague" }, + outputs: { output: { location: "Prague", unit: "celsius" } }, + }, + }, + }); + + const serializedTree = JSON.stringify(tree); + expect(serializedTree).not.toContain("user-secret"); + expect(serializedTree).not.toContain("weather-secret"); + }); +}); + +describe("processInputs / processOutputs", () => { + it("should apply processInputs to the root span", async () => { + const trace = createTrace({ + processInputs: (inputs) => ({ + ...inputs, + messages: (inputs.messages ?? []).map((m: any) => ({ + ...m, + content: "REDACTED", + })), + }), + }); + + await generateText({ + model: createModel(), + prompt: "Secret prompt", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { + inputs: { messages: [{ role: "user", content: "REDACTED" }] }, + }, + }, + }); + }); + + it("should apply processOutputs to the root span", async () => { + const trace = createTrace({ + processOutputs: (outputs) => ({ + ...outputs, + content: "REDACTED", + }), + }); + + await generateText({ + model: createModel({ + responses: [textResult("Secret response")], + }), + prompt: "Test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { outputs: { content: "REDACTED" } }, + }, + }); + }); + + it("should apply processChildLLMRunInputs to step spans", async () => { + const trace = createTrace({ + processChildLLMRunInputs: (inputs) => ({ + messages: (inputs.messages ?? []).map((m: any) => ({ + ...m, + content: "REDACTED_CHILD_INPUT", + })), + }), + }); + + await generateText({ + model: createModel(), + prompt: "Test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:1": { + inputs: { + messages: [{ role: "user", content: "REDACTED_CHILD_INPUT" }], + }, + }, + }, + }); + }); + + it("should apply processChildLLMRunOutputs to step spans", async () => { + const trace = createTrace({ + processChildLLMRunOutputs: (outputs) => ({ + ...outputs, + content: "REDACTED_CHILD_OUTPUT", + role: "assistant", + }), + }); + + await generateText({ + model: createModel({ + responses: [textResult("Secret step output")], + }), + prompt: "Test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:1": { + outputs: { + role: "assistant", + content: "REDACTED_CHILD_OUTPUT", + }, + }, + }, + }); + }); + + it("SECURITY: drops the inputs when processInputs throws (fail closed)", async () => { + const trace = createTrace({ + processInputs: () => { + throw new Error("redactor boom"); + }, + }); + + await generateText({ + model: createModel(), + prompt: "Secret prompt", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(JSON.stringify(tree.data["test-provider:0"])).not.toContain( + "Secret prompt", + ); + expect(tree.data["test-provider:0"].inputs).toEqual({ + ls_error: _processingFailed("inputs", "processInputs"), + }); + }); + + it("SECURITY: drops the outputs when processOutputs throws (fail closed)", async () => { + const trace = createTrace({ + processOutputs: () => { + throw new Error("redactor boom"); + }, + }); + + await generateText({ + model: createModel({ responses: [textResult("Secret response")] }), + prompt: "Test", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:0"].outputs).toEqual({ + ls_error: _processingFailed("outputs", "processOutputs"), + }); + }); + + it("SECURITY: drops the inputs when processChildLLMRunInputs throws", async () => { + const trace = createTrace({ + processChildLLMRunInputs: () => { + throw new Error("redactor boom"); + }, + }); + + await generateText({ + model: createModel(), + prompt: "Secret prompt", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:1"].inputs).toEqual({ + ls_error: _processingFailed("inputs", "processChildLLMRunInputs"), + }); + }); + + it("SECURITY: LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS restores raw inputs", async () => { + // eslint-disable-next-line no-process-env + process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS = "true"; + try { + const trace = createTrace({ + processInputs: () => { + throw new Error("redactor boom"); + }, + }); + + await generateText({ + model: createModel(), + prompt: "Secret prompt", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(JSON.stringify(tree.data["test-provider:0"])).toContain( + "Secret prompt", + ); + expect(tree.data["test-provider:0"].inputs).not.toHaveProperty( + "ls_error", + ); + } finally { + // eslint-disable-next-line no-process-env + delete process.env.LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS; + } + }); +}); + +describe("dotted order and trace hierarchy", () => { + it("should maintain correct parent-child dotted order", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel(), + prompt: "Hierarchy test", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree).toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + }); + + const root = tree.data["test-provider:0"]; + const step = tree.data["test-provider:1"]; + expect(step.trace_id).toBe(root.trace_id); + const rootDottedOrder = root.dotted_order; + const stepDottedOrder = step.dotted_order; + expect(rootDottedOrder).toEqual(expect.any(String)); + expect(stepDottedOrder).toEqual(expect.any(String)); + if ( + typeof rootDottedOrder !== "string" || + typeof stepDottedOrder !== "string" + ) { + throw new Error("Expected dotted order to be set on root and step runs"); + } + expect(stepDottedOrder.startsWith(rootDottedOrder)).toBe(true); + expect(stepDottedOrder.split(".").length).toBe( + rootDottedOrder.split(".").length + 1, + ); + }); + + it("should nest under an existing traceable context", async () => { + const trace = createTrace(); + const outerFn = traceable( + async () => { + await generateText({ + model: createModel(), + prompt: "Nested test", + telemetry: { integrations: [trace.integration] }, + }); + }, + { + name: "outer-traceable", + client: trace.client, + tracingEnabled: true, + }, + ); + + await outerFn(); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [ + ["outer-traceable:0", "test-provider:1"], + ["test-provider:1", "test-provider:2"], + ], + data: { + "outer-traceable:0": { name: "outer-traceable" }, + "test-provider:1": { + run_type: "chain", + extra: { + metadata: { + ls_integration: "vercel-ai-sdk-telemetry", + }, + }, + }, + "test-provider:2": { run_type: "llm" }, + }, + }); + }); +}); + +describe("traceResponseMetadata", () => { + it("should include steps in output when traceResponseMetadata is true", async () => { + const trace = createTrace({ + traceResponseMetadata: true, + }); + + await generateText({ + model: createModel({ + responses: [textResult("Step 1")], + }), + prompt: "Response metadata test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { + outputs: { + steps: [ + expect.objectContaining({ + step_number: 0, + content: [{ type: "text", text: "Step 1" }], + }), + ], + }, + }, + }, + }); + }); + + it("should not include steps when traceResponseMetadata is false", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel(), + prompt: "No metadata test", + telemetry: { integrations: [trace.integration] }, + }); + + const tree = await expectTree(trace); + expect(tree.data["test-provider:0"].outputs?.steps).toBeUndefined(); + }); +}); + +describe("streaming (onChunk)", () => { + it("should handle onChunk as a no-op without errors", async () => { + const trace = createTrace(); + + const result = streamText({ + model: createModel(), + prompt: "Stream test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(result.text).resolves.toBe("Hello world"); + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + run_type: "chain", + outputs: { + role: "assistant", + content: "Hello world", + finish_reason: "stop", + }, + }, + "test-provider:1": { + run_type: "llm", + outputs: { + role: "assistant", + content: [{ type: "text", text: "Hello world" }], + }, + }, + }, + }); + }); +}); + +describe("tracing disabled", () => { + it("should not create runs when tracing is disabled", async () => { + const trace = createTrace({ tracingEnabled: false }); + + await generateText({ + model: createModel(), + prompt: "Should not trace", + telemetry: { isEnabled: true, integrations: [trace.integration] }, + }); + await trace.client.awaitPendingTraceBatches(); + expect(trace.callSpy).not.toHaveBeenCalled(); + }); + + it("should not create runs when telemetry is disabled for a call", async () => { + const trace = createTrace({ tracingEnabled: true }); + + await generateText({ + model: createModel(), + prompt: "Should not trace", + telemetry: { isEnabled: false, integrations: [trace.integration] }, + }); + await trace.client.awaitPendingTraceBatches(); + expect(trace.callSpy).not.toHaveBeenCalled(); + }); +}); + +describe("project name configuration", () => { + it("should use custom project name", async () => { + const trace = createTrace({ + projectName: "my-custom-project", + }); + + await generateText({ + model: createModel(), + prompt: "Project test", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { session_name: "my-custom-project" }, + }, + }); + }); +}); + +describe("integration reuse", () => { + it("should create separate traces when reusing the same integration sequentially", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [textResult("First response")], + }), + prompt: "First call", + telemetry: { integrations: [trace.integration] }, + }); + + const firstTree = await expectTree(trace); + expect(firstTree).toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + inputs: { messages: [{ role: "user", content: "First call" }] }, + outputs: { content: "First response" }, + }, + }, + }); + const firstRoot = firstTree.data["test-provider:0"]; + + trace.callSpy.mockClear(); + + await generateText({ + model: createModel({ + responses: [ + textResult("Second response", usage({ input: 7, output: 4 })), + ], + }), + prompt: "Second call", + telemetry: { integrations: [trace.integration] }, + }); + + const secondTree = await expectTree(trace); + expect(secondTree).toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + inputs: { messages: [{ role: "user", content: "Second call" }] }, + outputs: { content: "Second response" }, + }, + }, + }); + const secondRoot = secondTree.data["test-provider:0"]; + expect(secondRoot.id).not.toBe(firstRoot.id); + expect(secondRoot.trace_id).not.toBe(firstRoot.trace_id); + }); + + it("should not leak state between sequential invocations after error", async () => { + const trace = createTrace(); + + await expect( + generateText({ + model: createModel({ + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK ERROR"); + }, + }), + prompt: "Error call", + telemetry: { integrations: [trace.integration] }, + }), + ).rejects.toThrow("TOTALLY EXPECTED MOCK ERROR"); + + await expect(expectTree(trace)).resolves.toMatchObject({ + data: { + "test-provider:0": { + error: expect.stringContaining("TOTALLY EXPECTED MOCK ERROR"), + }, + }, + }); + trace.callSpy.mockClear(); + + await generateText({ + model: createModel({ + responses: [textResult("Recovered")], + }), + prompt: "Recovery call", + telemetry: { integrations: [trace.integration] }, + }); + + await expect(expectTree(trace)).resolves.toMatchObject({ + edges: [["test-provider:0", "test-provider:1"]], + data: { + "test-provider:0": { + inputs: { messages: [{ role: "user", content: "Recovery call" }] }, + outputs: { content: "Recovered" }, + }, + }, + }); + }); + + it("should handle reuse with tools across invocations", async () => { + const trace = createTrace(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-1", + toolName: "search", + input: { query: "first" }, + }), + textResult("First answer", usage({ input: 20, output: 8 })), + ], + }), + prompt: "First tool call", + tools: { + search: tool({ + inputSchema: z.object({ query: z.string() }), + execute: async () => "first result", + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + const firstTree = await expectTree(trace); + const firstTraceId = firstTree.data["test-provider:0"].trace_id; + trace.callSpy.mockClear(); + + await generateText({ + model: createModel({ + responses: [ + toolCallResult({ + toolCallId: "tc-2", + toolName: "calculator", + input: { expression: "1+1" }, + }), + textResult("1+1 = 2", usage({ input: 15, output: 5 })), + ], + }), + prompt: "Second tool call", + tools: { + calculator: tool({ + inputSchema: z.object({ expression: z.string() }), + execute: async () => 2, + }), + }, + stopWhen: isStepCount(10), + telemetry: { integrations: [trace.integration] }, + }); + + const secondTree = await expectTree(trace); + expect(secondTree).toMatchObject({ + edges: [ + ["test-provider:0", "test-provider:1"], + ["test-provider:0", "calculator:2"], + ["test-provider:0", "test-provider:3"], + ], + data: { + "calculator:2": { + run_type: "tool", + inputs: { expression: "1+1" }, + outputs: { output: 2 }, + }, + }, + }); + expect(secondTree.data["test-provider:0"].trace_id).not.toBe(firstTraceId); + }); +}); diff --git a/src/lib/tests/vercel/telemetry/openai.int.test.ts b/src/lib/tests/vercel/telemetry/openai.int.test.ts new file mode 100644 index 0000000..53c95db --- /dev/null +++ b/src/lib/tests/vercel/telemetry/openai.int.test.ts @@ -0,0 +1,991 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { openai } from "@ai-sdk/openai"; +import { jest } from "@jest/globals"; +import * as ai from "ai"; +import z from "zod"; + +import { LangSmithTelemetry } from "../../../experimental/vercel/telemetry.js"; +import { getAssumedTreeFromCalls } from "../../utils/tree.js"; +import { traceable } from "../../../traceable.js"; +import { Client } from "../../../index.js"; + +const { tool, stepCountIs } = ai; + +class GreaterThanMatcher { + constructor(private threshold: number) {} + asymmetricMatch(actual: unknown) { + return typeof actual === "number" && actual > this.threshold; + } + getExpectedType() { + return "number"; + } +} + +test("telemetry generateText basic", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const model = openai("gpt-5-nano"); + const userMessage = "What color is the sky? Answer in one word: blue."; + + const result = await ai.generateText({ + model, + messages: [{ role: "user", content: userMessage }], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(runs).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + content: expect.stringMatching(/blue/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/blue/i), + }), + ]), + finish_reason: "stop", + }, + extra: { + metadata: { + usage_metadata: { + input_tokens: new GreaterThanMatcher(0), + output_tokens: new GreaterThanMatcher(0), + }, + }, + }, + }, + }, + }); + expect(runs.data["openai.responses:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); +}, 30_000); + +test("telemetry generateText with tools", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const userContent = + "What are my orders? My user ID is 123. Always use tools."; + + const result = await ai.generateText({ + model: openai("gpt-5-nano"), + messages: [{ role: "user", content: userContent }], + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree).toMatchObject({ + edges: [ + ["openai.responses:0", "openai.responses:1"], + ["openai.responses:0", "listOrders:2"], + ["openai.responses:0", "openai.responses:3"], + ], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userContent }], + tools: ["listOrders"], + }, + outputs: { + content: expect.stringMatching(/order/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { + metadata: { step_number: 0 }, + invocation_params: { + tools: [ + expect.objectContaining({ + name: "listOrders", + input_schema: expect.objectContaining({ type: "object" }), + }), + ], + }, + }, + inputs: { + messages: [{ role: "user", content: userContent }], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "listOrders", + }), + ]), + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "listOrders" }), + }), + ]), + finish_reason: "tool-calls", + }, + }, + "listOrders:2": { + run_type: "tool", + inputs: { userId: "123" }, + outputs: { + output: expect.stringMatching(/User 123 has the following orders/), + }, + }, + "openai.responses:3": { + run_type: "llm", + extra: { metadata: { step_number: 1 } }, + inputs: { + messages: expect.arrayContaining([ + { role: "user", content: userContent }, + expect.objectContaining({ role: "assistant" }), + expect.objectContaining({ role: "tool" }), + ]), + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/order/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); + + expect( + tree.data["openai.responses:1"].extra?.invocation_params?.tools?.[0], + ).not.toHaveProperty("inputSchema"); + expect(tree.data["openai.responses:0"].extra?.metadata).not.toHaveProperty( + "usage_metadata", + ); + expect( + tree.data["openai.responses:1"].extra?.metadata?.usage_metadata + ?.total_tokens, + ).toBeGreaterThan(0); + expect( + tree.data["openai.responses:3"].extra?.metadata?.usage_metadata + ?.total_tokens, + ).toBeGreaterThan(0); +}, 30_000); + +test("telemetry streamText", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const userContent = + "What are my orders? My user ID is 123. Always use tools."; + const streamResult = ai.streamText({ + model: openai("gpt-5-nano"), + messages: [{ role: "user", content: userContent }], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + + let total = ""; + for await (const chunk of streamResult.textStream) { + total += chunk; + } + expect(total.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(runs).toMatchObject({ + edges: [ + ["openai.responses:0", "openai.responses:1"], + ["openai.responses:0", "listOrders:2"], + ["openai.responses:0", "openai.responses:3"], + ], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userContent }], + tools: ["listOrders"], + }, + outputs: { + content: expect.stringMatching(/order/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { + metadata: { step_number: 0 }, + invocation_params: { + tools: [ + expect.objectContaining({ + name: "listOrders", + input_schema: expect.objectContaining({ type: "object" }), + }), + ], + }, + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "listOrders", + }), + ]), + tool_calls: expect.arrayContaining([ + expect.objectContaining({ + type: "function", + function: expect.objectContaining({ name: "listOrders" }), + }), + ]), + finish_reason: "tool-calls", + }, + }, + "listOrders:2": { + run_type: "tool", + inputs: { userId: "123" }, + outputs: { + output: expect.stringMatching(/User 123 has the following orders/), + }, + }, + "openai.responses:3": { + run_type: "llm", + extra: { metadata: { step_number: 1 } }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/order/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); + + expect( + runs.data["openai.responses:1"].extra?.invocation_params?.tools?.[0], + ).not.toHaveProperty("inputSchema"); +}, 30_000); + +test("telemetry generateText with flex service tier", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const userMessage = "What color is the sky in one word: blue."; + + const result = await ai.generateText({ + model: openai("gpt-5-mini"), + messages: [{ role: "user", content: userMessage }], + providerOptions: { openai: { serviceTier: "flex" } }, + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.text.length).toBeGreaterThan(0); + await client.awaitPendingTraceBatches(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + content: expect.stringMatching(/blue/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/blue/i), + }), + ]), + finish_reason: "stop", + }, + extra: { + metadata: { + step_number: 0, + usage_metadata: { + input_tokens: new GreaterThanMatcher(0), + output_tokens: new GreaterThanMatcher(0), + input_token_details: { flex: new GreaterThanMatcher(0) }, + output_token_details: { + flex: new GreaterThanMatcher(0), + flex_reasoning: new GreaterThanMatcher(0), + }, + }, + }, + }, + }, + }, + }); +}, 30_000); + +test("telemetry generateObject", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const schema = z.object({ color: z.string() }); + const userMessage = "What color is the sky? Respond with JSON only."; + + const result = await ai.generateText({ + model: openai("gpt-5-nano"), + messages: [{ role: "user", content: userMessage }], + output: ai.Output.object({ schema }), + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.output).toBeDefined(); + expect(schema.parse(result.output)).toBeDefined(); + + await client.awaitPendingTraceBatches(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + content: expect.stringMatching(/"color"\s*:\s*"blue"/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { metadata: { step_number: 0 } }, + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/"color"\s*:\s*"blue"/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); +}, 30_000); + +test("telemetry streamObject via streamText with output", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const schema = z.object({ color: z.string() }); + const userMessage = "What color is the sky? Respond with JSON only."; + + const streamResult = ai.streamText({ + model: openai("gpt-5-nano"), + messages: [{ role: "user", content: userMessage }], + output: ai.Output.object({ schema }), + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + const chunks: unknown[] = []; + for await (const chunk of streamResult.partialOutputStream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(schema.parse(chunks.at(-1))).toBeDefined(); + + await client.awaitPendingTraceBatches(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + content: expect.stringMatching(/"color"\s*:\s*"blue"/i), + finish_reason: "stop", + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { metadata: { step_number: 0 } }, + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "text", + text: expect.stringMatching(/"color"\s*:\s*"blue"/i), + }), + ]), + finish_reason: "stop", + }, + }, + }, + }); +}, 30_000); + +test("telemetry stream cancellation should finish spans cleanly", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const abortController = new AbortController(); + const userMessage = "Tell me a long story about a cat."; + + const streamResult = ai.streamText({ + model: openai("gpt-5-nano"), + messages: [{ role: "user", content: userMessage }], + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + abortSignal: abortController.signal, + }); + + let abortedAfterDeltas = false; + let i = 0; + try { + for await (const chunk of streamResult.fullStream) { + if (chunk.type === "text-delta") { + if (i++ > 5) { + abortController.abort(); + abortedAfterDeltas = true; + } + } + } + } catch { + // Abort may throw + } + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { metadata: { step_number: 0 } }, + inputs: { + messages: [{ role: "user", content: userMessage }], + }, + }, + }, + }); + + if (abortedAfterDeltas) { + expect(runs.data["openai.responses:0"].end_time).toBeUndefined(); + expect(runs.data["openai.responses:1"].end_time).toBeUndefined(); + } +}, 30_000); + +test("telemetry processInputs and processOutputs", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const integration = LangSmithTelemetry({ + client, + processInputs: (inputs) => ({ + ...inputs, + prompt: "REDACTED", + messages: (inputs.messages ?? []).map((m: Record) => ({ + ...m, + content: "REDACTED", + })), + }), + processOutputs: (outputs) => ({ + ...outputs, + content: "REDACTED", + }), + processChildLLMRunInputs: (inputs) => ({ + messages: (inputs.messages ?? []).map((m: Record) => ({ + ...m, + content: "REDACTED CHILD INPUTS", + })), + }), + processChildLLMRunOutputs: (outputs) => ({ + ...outputs, + content: "REDACTED CHILD OUTPUTS", + role: "assistant", + }), + }); + + const result = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "What is the capital of France? Answer: Paris.", + telemetry: { integrations: [integration] }, + }); + + expect(result.text).not.toContain("REDACTED"); + + await client.awaitPendingTraceBatches(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + inputs: { prompt: "REDACTED" }, + outputs: { content: "REDACTED" }, + }, + "openai.responses:1": { + inputs: { + messages: [ + expect.objectContaining({ content: "REDACTED CHILD INPUTS" }), + ], + }, + outputs: { content: "REDACTED CHILD OUTPUTS" }, + }, + }, + }); +}, 30_000); + +test("telemetry nested under traceable parent", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const outerFn = traceable( + async () => { + const result = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "What color is the sky? One word: blue.", + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + return result.text; + }, + { name: "outer-traceable", client }, + ); + + const text = await outerFn(); + expect(text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(runs).toMatchObject({ + nodes: ["outer-traceable:0", "openai.responses:1", "openai.responses:2"], + edges: [ + ["outer-traceable:0", "openai.responses:1"], + ["openai.responses:1", "openai.responses:2"], + ], + + data: { + "outer-traceable:0": { + name: "outer-traceable", + }, + "openai.responses:1": { + trace_id: runs.data["outer-traceable:0"].trace_id, + run_type: "chain", + extra: { + metadata: expect.objectContaining({ + ls_integration: "vercel-ai-sdk-telemetry", + }), + }, + }, + "openai.responses:2": expect.objectContaining({ + run_type: "llm", + trace_id: runs.data["outer-traceable:0"].trace_id, + }), + }, + }); +}, 30_000); + +test("telemetry tool with nested traceable (sub-agent pattern)", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const integration = LangSmithTelemetry({ + client, + tracingEnabled: true, + }); + + const subAgent = traceable( + async (query: string) => { + const subagent = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: `Only say '${query}' and nothing else`, + telemetry: { integrations: [integration] }, + }); + + return subagent.text; + }, + { name: "sub-agent", run_type: "chain", client }, + ); + + await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "Call the research tool to search for 'AI trends'", + tools: { + research: tool({ + description: "Search for AI trends", + inputSchema: z.object({ query: z.string() }), + execute: async ({ query }) => subAgent(query), + }), + }, + telemetry: { integrations: [integration] }, + }); + + await client.awaitPendingTraceBatches(); + const runs = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + expect(runs).toMatchObject({ + edges: [ + ["openai.responses:0", "openai.responses:1"], + ["openai.responses:0", "research:2"], + ["research:2", "sub-agent:3"], + ["sub-agent:3", "openai.responses:4"], + ["openai.responses:4", "openai.responses:5"], + ], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: "Call the research tool to search for 'AI trends'", + }, + ], + tools: ["research"], + }, + outputs: { + tool_calls: [{ type: "function", function: { name: "research" } }], + finish_reason: "tool-calls", + }, + }, + "openai.responses:1": { + run_type: "llm", + extra: { + invocation_params: { + tools: [ + expect.objectContaining({ + name: "research", + input_schema: expect.objectContaining({ type: "object" }), + }), + ], + }, + }, + inputs: { + messages: [ + { + role: "user", + content: "Call the research tool to search for 'AI trends'", + }, + ], + }, + outputs: { + role: "assistant", + content: expect.arrayContaining([ + expect.objectContaining({ + type: "tool-call", + toolName: "research", + }), + ]), + tool_calls: [{ type: "function", function: { name: "research" } }], + finish_reason: "tool-calls", + }, + }, + "research:2": { + run_type: "tool", + inputs: { query: "AI trends" }, + outputs: { output: "AI trends" }, + }, + "sub-agent:3": { + run_type: "chain", + inputs: { input: "AI trends" }, + outputs: { outputs: "AI trends" }, + }, + "openai.responses:4": { + run_type: "chain", + inputs: { + messages: [ + { + role: "user", + content: "Only say 'AI trends' and nothing else", + }, + ], + }, + outputs: { content: "AI trends", finish_reason: "stop" }, + }, + "openai.responses:5": { + run_type: "llm", + inputs: { + messages: [ + { + role: "user", + content: "Only say 'AI trends' and nothing else", + }, + ], + }, + outputs: { role: "assistant", finish_reason: "stop" }, + }, + }, + }); +}, 30_000); + +test("telemetry tool error handling", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const result = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "Get current weather in Tokyo", + tools: { + getWeather: tool({ + description: "Get current weather in Tokyo", + inputSchema: z.object({ city: z.string() }), + execute: async (): Promise => { + throw new Error("No data found"); + }, + }), + }, + stopWhen: stepCountIs(1), + telemetry: { + integrations: [LangSmithTelemetry({ client, tracingEnabled: true })], + }, + }); + + expect(result.content.at(-1)).toMatchObject({ + type: "tool-error", + toolName: "getWeather", + error: expect.any(Error), + }); + + await client.awaitPendingTraceBatches(); + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + edges: [ + ["openai.responses:0", "openai.responses:1"], + ["openai.responses:0", "getWeather:2"], + ], + data: { + "openai.responses:0": { + run_type: "chain", + inputs: { + messages: [{ role: "user", content: "Get current weather in Tokyo" }], + tools: ["getWeather"], + }, + }, + "openai.responses:1": { run_type: "llm" }, + "getWeather:2": { + run_type: "tool", + inputs: { city: "Tokyo" }, + error: "No data found", + }, + }, + }); +}, 30_000); + +test("telemetry reuse across sequential generateText calls", async () => { + const callSpy = jest.fn(fetch); + const client = new Client({ + autoBatchTracing: false, + fetchImplementation: callSpy, + }); + + const integration = LangSmithTelemetry({ + client, + tracingEnabled: true, + }); + + const result1 = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "What color is the sky? One word: blue.", + telemetry: { integrations: [integration] }, + }); + + expect(result1.text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const firstRuns = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(firstRuns).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + inputs: { + messages: [ + { + role: "user", + content: "What color is the sky? One word: blue.", + }, + ], + }, + outputs: { content: expect.stringMatching(/blue/i) }, + }, + "openai.responses:1": expect.objectContaining({ run_type: "llm" }), + }, + }); + const firstChain = firstRuns.data["openai.responses:0"]!; + + const firstPosts = callSpy.mock.calls.filter( + (call) => (call[1] as { method?: string })?.method === "POST", + ); + expect(firstPosts.length).toBe(2); + + callSpy.mockClear(); + + const result2 = await ai.generateText({ + model: openai("gpt-5-nano"), + prompt: "What color is grass? One word: green.", + telemetry: { integrations: [integration] }, + }); + + expect(result2.text.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + const secondRuns = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(secondRuns).toMatchObject({ + edges: [["openai.responses:0", "openai.responses:1"]], + data: { + "openai.responses:0": { + inputs: { + messages: [ + { + role: "user", + content: "What color is grass? One word: green.", + }, + ], + }, + outputs: { content: expect.stringMatching(/green/i) }, + }, + "openai.responses:1": expect.objectContaining({ run_type: "llm" }), + }, + }); + const secondChain = secondRuns.data["openai.responses:0"]!; + + expect(secondChain.id).not.toBe(firstChain.id); + expect(secondChain.trace_id).not.toBe(firstChain.trace_id); + + const secondPosts = callSpy.mock.calls.filter( + (call) => (call[1] as { method?: string })?.method === "POST", + ); + expect(secondPosts.length).toBe(2); +}, 30_000); diff --git a/src/lib/tests/vercel/utils.ts b/src/lib/tests/vercel/utils.ts new file mode 100644 index 0000000..35dc01e --- /dev/null +++ b/src/lib/tests/vercel/utils.ts @@ -0,0 +1,117 @@ +import { LanguageModelV2 } from "@ai-sdk/provider"; + +export class ExecutionOrderSame { + $$typeof = Symbol.for("jest.asymmetricMatcher"); + + private expectedNs: string; + private expectedDepth: number; + + constructor(depth: number, ns: string) { + this.expectedDepth = depth; + this.expectedNs = ns; + } + + asymmetricMatch(other: unknown) { + if (!(typeof other === "string" || other instanceof String)) { + return false; + } + + const segments = other.split("."); + if (segments.length !== this.expectedDepth) return false; + + const last = segments.at(-1); + if (!last) return false; + + const nanoseconds = last.split("Z").at(0)?.slice(-3); + return nanoseconds === this.expectedNs; + } + + toString() { + return "ExecutionOrderSame"; + } + + getExpectedType() { + return "string"; + } + + toAsymmetricMatcher() { + return `ExecutionOrderSame<${this.expectedDepth}, ${this.expectedNs}>`; + } +} + +// Test code from https://github.com/vercel/ai/blob/main/packages/ai/src/test/mock-language-model-v2.ts +// It seems the old models are no longer exported + +export function notImplemented(): never { + throw new Error("Not implemented"); +} + +export class MockLanguageModelV2 implements LanguageModelV2 { + readonly specificationVersion = "v2"; + + private _supportedUrls: () => LanguageModelV2["supportedUrls"]; + + readonly provider: LanguageModelV2["provider"]; + readonly modelId: LanguageModelV2["modelId"]; + + doGenerate: LanguageModelV2["doGenerate"]; + doStream: LanguageModelV2["doStream"]; + + doGenerateCalls: Parameters[0][] = []; + doStreamCalls: Parameters[0][] = []; + + constructor({ + provider = "mock-provider", + modelId = "mock-model-id", + supportedUrls = {}, + doGenerate = notImplemented, + doStream = notImplemented, + }: { + provider?: LanguageModelV2["provider"]; + modelId?: LanguageModelV2["modelId"]; + supportedUrls?: + | LanguageModelV2["supportedUrls"] + | (() => LanguageModelV2["supportedUrls"]); + doGenerate?: + | LanguageModelV2["doGenerate"] + | Awaited> + | Awaited>[]; + doStream?: + | LanguageModelV2["doStream"] + | Awaited> + | Awaited>[]; + } = {}) { + this.provider = provider; + this.modelId = modelId; + this.doGenerate = async (options) => { + this.doGenerateCalls.push(options); + + if (typeof doGenerate === "function") { + return doGenerate(options); + } else if (Array.isArray(doGenerate)) { + return doGenerate[this.doGenerateCalls.length]; + } else { + return doGenerate; + } + }; + this.doStream = async (options) => { + this.doStreamCalls.push(options); + + if (typeof doStream === "function") { + return doStream(options); + } else if (Array.isArray(doStream)) { + return doStream[this.doStreamCalls.length]; + } else { + return doStream; + } + }; + this._supportedUrls = + typeof supportedUrls === "function" + ? supportedUrls + : async () => supportedUrls; + } + + get supportedUrls() { + return this._supportedUrls(); + } +} diff --git a/src/lib/tests/vercel/wrapper/methods.vitesttest.ts b/src/lib/tests/vercel/wrapper/methods.vitesttest.ts new file mode 100644 index 0000000..89f23a7 --- /dev/null +++ b/src/lib/tests/vercel/wrapper/methods.vitesttest.ts @@ -0,0 +1,3053 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import { MockLanguageModelV3 } from "ai/test"; +import * as ai from "ai"; +import { simulateReadableStream, APICallError } from "ai"; +import { z } from "zod"; +import { describe, it, beforeEach, expect } from "vitest"; +import { + createLangSmithProviderOptions, + wrapAISDK, +} from "../../../experimental/vercel/index.js"; +import { traceable } from "../../../traceable.js"; + +// Track HTTP requests made by the real traceable function +const mockHttpRequests: any[] = []; + +// Mock LangSmith Client that captures HTTP calls +class MockLangSmithClient { + async createRun(runCreate: any) { + mockHttpRequests.push({ + method: "POST", + endpoint: "/runs", + body: runCreate, + type: "createRun", + timestamp: Date.now(), + }); + return { id: `mock-run-${Date.now()}` }; + } + + async updateRun(runId: string, runUpdate: any) { + mockHttpRequests.push({ + method: "PATCH", + endpoint: `/runs/${runId}`, + body: runUpdate, + type: "updateRun", + timestamp: Date.now(), + }); + return { id: runId }; + } +} + +const standardMockedModel = new MockLanguageModelV3({ + modelId: "object-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"name": "John", "age": 30}', + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 18, + }, + warnings: [], + }), + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: '{"name": "John", "age": 30}', + }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 15, + }, + }, + ], + }), + }), +}); + +describe("wrapAISDK", () => { + let mockClient: MockLangSmithClient; + + beforeEach(() => { + // Reset HTTP requests and create fresh mock client + process.env.LANGSMITH_TRACING = "true"; + mockHttpRequests.length = 0; + mockClient = new MockLangSmithClient(); + }); + + describe("tracing integration", () => { + it("should create traceable function calls", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + const mockLangModel = new MockLanguageModelV3({ + modelId: "trace-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made to LangSmith in correct order + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + // The first createRun should be the high-level generateText call + const generateTextRun = mockHttpRequests[0]; + expect(generateTextRun.body.extra.metadata).toHaveProperty( + "ai_sdk_method", + "ai.generateText", + ); + expect(generateTextRun.body.extra.metadata).toHaveProperty( + "ls_integration", + "vercel-ai-sdk", + ); + + // The second createRun should be the low-level doGenerate call + const doGenerateRun = mockHttpRequests[1]; + expect(doGenerateRun.body.extra.metadata).toHaveProperty( + "ai_sdk_method", + "ai.doGenerate", + ); + expect(doGenerateRun.body.extra.metadata).toHaveProperty( + "ls_integration", + "vercel-ai-sdk", + ); + }); + + it("should apply custom metadata in tracing config", async () => { + const wrappedWithMetadata = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { + name: "custom-tracer", + metadata: { + customField: "test-value", + version: "2.0", + }, + client: mockClient as any, + }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "metadata-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Metadata test" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedWithMetadata.generateText({ + model: mockLangModel, + prompt: "Test with metadata", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify custom metadata was applied in HTTP requests + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + // The first createRun should have the AI SDK metadata with custom fields + const generateTextRun = mockHttpRequests[0]; + const updateTextRun = mockHttpRequests[3]; + expect(generateTextRun.body.extra.metadata).toMatchObject({ + ls_integration: "vercel-ai-sdk", + customField: "test-value", + version: "2.0", + ai_sdk_method: "ai.generateText", + }); + expect(updateTextRun.body.outputs).not.toHaveProperty( + "response_metadata", + ); + }); + + it("should allow configuring traceResponseMetadata", async () => { + const wrappedWithMetadata = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { + name: "custom-tracer", + traceResponseMetadata: true, + client: mockClient as any, + }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "metadata-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Metadata test" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedWithMetadata.generateText({ + model: mockLangModel, + prompt: "Test with traced steps", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests).toMatchObject([ + { type: "createRun" }, + { type: "createRun" }, + { type: "updateRun" }, + { type: "updateRun" }, + ]); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + const updateTextRun = mockHttpRequests[3]; + expect(updateTextRun.body.outputs.response_metadata).toHaveProperty( + "steps", + ); + }); + + it("should send error information to LangSmith on model failures", async () => { + // Create wrapped methods with mock client for this test + const wrappedMethodsWithMock = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "error-test-model", + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOGENERATE ERROR"); + }, + }); + + try { + await wrappedMethodsWithMock.generateText({ + model: mockLangModel, + prompt: "This should fail", + }); + expect(true).toBe(false); // Should not reach here + } catch (error: any) { + expect(error.message).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + } + + // Add a small delay to allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured and sent to LangSmith + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCall = mockHttpRequests.find( + (req) => req.type === "createRun", + ); + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toHaveProperty( + "prompt", + "This should fail", + ); + + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + }); + + it("should handle streamText with proper aggregation", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-test-model", + doStream: async () => ({ + response: {}, + request: {}, + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " " }, + { type: "text-delta", id: "text-1", delta: "world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + totalTokens: 7, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Say hello", + }); + + // Consume the stream to trigger aggregation + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Hello world"); + + // Verify HTTP requests were made for streamText + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const generateTextPostRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPostRun).toBeDefined(); + const generateTextPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPatchRun).toBeDefined(); + expect(generateTextPatchRun.body.outputs.content).toMatchObject([ + { + type: "text", + text: "Hello world", + }, + ]); + + // Verify that by default (traceRawHttp not set), request/response are excluded + const doStreamUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + expect(doStreamUpdateRun).toBeDefined(); + expect(doStreamUpdateRun.body.outputs.request).toBeUndefined(); + expect(doStreamUpdateRun.body.outputs.response).toBeUndefined(); + }); + + it("should delay a parent traceable's end time until the child traceable ends", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-test-model", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " " }, + { type: "text-delta", id: "text-1", delta: "world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + totalTokens: 7, + }, + }, + ], + chunkDelayInMs: 10, + }), + }), + }); + + const start = new Date(); + const result = await traceable( + async () => { + return wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Say hello", + }); + }, + { + client: mockClient as any, + name: "parent-traceable", + }, + )(); + + // Should return quickly + expect(new Date().getTime() - start.getTime()).toBeLessThan(50); + + // Consume the stream to trigger aggregation + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(new Date().getTime() - start.getTime()).toBeGreaterThanOrEqual(50); + + expect(fullText).toBe("Hello world"); + + // Verify HTTP requests were made for streamText + expect(mockHttpRequests.length).toBe(6); // 3 createRun + 3 updateRun + + const parentCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && req.body.name === "parent-traceable", + ); + expect(parentCreateRun).toBeDefined(); + const parentUpdateRun = mockHttpRequests.find( + (req) => req.type === "updateRun" && !req.body.parent_run_id, + ); + expect(parentUpdateRun).toBeDefined(); + const generateTextPostRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPostRun).toBeDefined(); + const generateTextPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPatchRun).toBeDefined(); + expect(generateTextPatchRun.body.outputs.content).toMatchObject([ + { + type: "text", + text: "Hello world", + }, + ]); + const generateLLMPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doStream", + ); + expect(generateLLMPatchRun).toBeDefined(); + expect(parentUpdateRun.body.end_time).toBeGreaterThanOrEqual( + generateTextPatchRun.body.end_time, + ); + expect(parentUpdateRun.body.end_time).toBeGreaterThanOrEqual( + generateLLMPatchRun.body.end_time, + ); + }); + + it("should handle streamText errors properly", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + // Test error at doStream level + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-error-model", + doStream: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOSTREAM ERROR"); + }, + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "This should fail", + }); + + // Stream should be empty or error out when consumed due to doStream failure + for await (const textPart of result.textStream) { + console.log("Unexpected text part:", textPart); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith traces + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOSTREAM ERROR", + ); + }); + + it("should respect traceRawHttp for streamText", async () => { + const wrappedWithRawHttp = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { + name: "raw-http-tracer", + traceRawHttp: true, + client: mockClient as any, + }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "raw-http-test-model", + doStream: async () => ({ + response: {}, + request: {}, + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 2, text: 2, reasoning: 0 }, + totalTokens: 7, + }, + }, + ], + }), + }), + }); + + const result = wrappedWithRawHttp.streamText({ + model: mockLangModel, + prompt: "Say hello", + providerOptions: { + langsmith: createLangSmithProviderOptions({}), + }, + }); + + // Consume the stream + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Hello world"); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the doStream child run update + const doStreamUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + + expect(doStreamUpdateRun).toBeDefined(); + // When traceRawHttp is true, request and response should be in the outputs + expect(doStreamUpdateRun.body.outputs.request).toBeDefined(); + expect(doStreamUpdateRun.body.outputs.response).toBeDefined(); + }); + + it("should handle generateObject with proper output processing", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "object-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"name": "John", "age": 30}', + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 18, + }, + warnings: [], + }), + }); + + const result = await wrappedMethods.generateObject({ + model: mockLangModel, + prompt: "Generate a person object", + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }); + + expect(result.object).toEqual({ name: "John", age: 30 }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made for generateObject + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const generateObjectRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateObject", + ); + expect(generateObjectRun).toBeDefined(); + }); + + it.each(["generateText", "streamText"])( + "should handle %s with an object output schema", + async (method) => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const output = ai.Output.object({ + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }); + + const result = await (wrappedMethods as any)[method]({ + model: standardMockedModel, + prompt: "Generate a person object", + output, + }); + + const directOutput = + method === "generateText" ? result.output : await result.output; + expect(directOutput).toEqual({ name: "John", age: 30 }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made for generateObject + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const createRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + const updateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + expect(createRun).toBeDefined(); + expect(updateRun.body.outputs).toEqual({ + name: "John", + age: 30, + }); + expect(createRun.body.inputs.output).toEqual( + await output.responseFormat, + ); + }, + ); + + it.each(["generateText", "streamText"])( + "should handle %s with text output", + async (method) => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const output = ai.Output.text(); + + const result = await (wrappedMethods as any)[method]({ + model: standardMockedModel, + prompt: "Generate a person object", + output, + }); + + const directOutput = + method === "generateText" ? result.output : await result.output; + expect(directOutput).toEqual('{"name": "John", "age": 30}'); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made for generateObject + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const createRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + const updateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + expect(createRun).toBeDefined(); + expect(typeof updateRun.body.outputs).toEqual("object"); + expect(createRun.body.inputs.output).toEqual( + await output.responseFormat, + ); + }, + ); + + it.each(["generateText", "streamText"])( + "should handle %s with array output", + async (method) => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const output = ai.Output.array({ + element: z.string(), + }); + + const standardMockedModel = new MockLanguageModelV3({ + modelId: "object-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"elements": ["foo"]}', + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 18, + }, + warnings: [], + }), + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: '{"elements": ["foo"]}', + }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 15, + }, + }, + ], + }), + }), + }); + + const result = await (wrappedMethods as any)[method]({ + model: standardMockedModel, + prompt: "Generate a person object", + output, + }); + + const directOutput = + method === "generateText" ? result.output : await result.output; + expect(directOutput).toEqual(["foo"]); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made for generateObject + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const createRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + + const patchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === `ai.${method}`, + ); + expect(createRun).toBeDefined(); + expect(typeof patchRun.body.outputs).toEqual("object"); + // Should be wrapped + expect(Array.isArray(patchRun.body.outputs)).toEqual(false); + expect(createRun.body.inputs.output).toEqual( + await output.responseFormat, + ); + }, + ); + + it("should handle generateObject errors", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "object-error-model", + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOGENERATE ERROR"); + }, + }); + + try { + await wrappedMethods.generateObject({ + model: mockLangModel, + prompt: "This should fail", + schema: z.object({ + name: z.string(), + }), + }); + expect(true).toBe(false); // Should not reach here + } catch (error: any) { + expect(error.message).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + }); + + it("should handle generateObject with explicit generics", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "object-generics-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"key": "test-value"}', + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 18, + }, + warnings: [], + }), + }); + + const result = await wrappedMethods.generateObject< + z.ZodObject<{ key: z.ZodString; keybar: z.ZodString }>, + "object", + { key: string; keybar: string } + >({ + model: mockLangModel, + prompt: "Generate an object with a key", + // @ts-expect-error - bad schema for test + schema: z.object({ + key: z.string(), + }), + }); + + void result.object.key; + void result.object.keybar; + // @ts-expect-error - test nonpresent keys throw type error + void result.object.keybaz; + + expect(result.object).toEqual({ key: "test-value" }); + }); + + it("should create LangSmith traces for streamObject operations", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-object-trace-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: '{ "name": "John" }' }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 15, + }, + }, + ], + }), + }), + }); + + const { partialObjectStream } = wrappedMethods.streamObject({ + model: mockLangModel, + prompt: "Generate an object", + schema: z.object({ + name: z.string(), + }), + }); + + const chunks: unknown[] = []; + for await (const partialObject of partialObjectStream) { + chunks.push(partialObject); + } + expect(chunks.length).toEqual(1); + expect(chunks.at(-1)).toMatchObject({ + name: "John", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests.length).toEqual(4); + + const streamObjectRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamObject", + ); + expect(streamObjectRun).toBeDefined(); + const streamObjectPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamObject", + ); + expect(streamObjectPatchRun).toBeDefined(); + expect(streamObjectPatchRun.body.outputs).toMatchObject({ + name: "John", + }); + }); + + it("should handle streamObject errors properly", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + // Test error at doStream level for streamObject + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-object-error-model", + doStream: async () => { + throw new Error("TOTALLY EXPECTED MOCK STREAMOBJECT DOSTREAM ERROR"); + }, + }); + + const result = wrappedMethods.streamObject({ + model: mockLangModel, + prompt: "This should fail", + schema: z.object({ + name: z.string(), + }), + }); + + // Stream should be empty or error out when consumed due to doStream failure + for await (const partialObject of result.partialObjectStream) { + console.log("Unexpected partial object:", partialObject); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith traces + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK STREAMOBJECT DOSTREAM ERROR", + ); + }); + + it("should handle rate limit retries in streaming", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + let callCount = 0; + const mockLangModel = new MockLanguageModelV3({ + modelId: "rate-limit-test-model", + doStream: async () => { + callCount++; + if (callCount <= 2) { + // First two calls hit rate limit + const delaySeconds = 0; + throw new APICallError({ + message: "Rate limit reached for requests", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 429, + isRetryable: true, + data: { + error: { + message: "Rate limit reached for requests", + type: "requests", + param: null, + code: "rate_limit_exceeded", + }, + }, + responseHeaders: { + "retry-after": delaySeconds.toString(), + "x-request-id": "req_abcdef123456", + }, + }); + } + + // Third call succeeds + return { + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: "Success after retry", + }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + }, + ], + }), + }; + }, + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Test rate limit retry", + }); + + // Consume the stream to verify it eventually succeeds + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Success after retry"); + expect(callCount).toBe(3); // Should have retried twice + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP request patterns for streaming retries + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCalls = mockHttpRequests.filter( + (req) => req.type === "createRun", + ); + const updateRunCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun", + ); + + // With 3 doStream attempts (2 failures + 1 success), we should see: + // 4 createRun calls: 1 parent + 3 child doStream attempts + // 4 updateRun calls: 1 parent success + 2 child failures + 1 child success + expect(createRunCalls.length).toBe(4); + expect(updateRunCalls.length).toBe(4); + + // Verify the success/error pattern: 1 parent success + 2 child errors + 1 child success + const successUpdateCalls = mockHttpRequests.filter( + (req) => + req.type === "updateRun" && !req.body.error && req.body.outputs, + ); + const errorUpdateCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(successUpdateCalls.length).toBe(2); // 1 parent streamText + 1 child doStream success + expect(errorUpdateCalls.length).toBe(2); // 2 child doStream failures + }); + + it("should handle rate limit retries in generateText", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + let callCount = 0; + const mockLangModel = new MockLanguageModelV3({ + modelId: "generatetext-rate-limit-test-model", + doGenerate: async () => { + callCount++; + if (callCount <= 2) { + // First two calls hit rate limit + const delaySeconds = 0; + throw new APICallError({ + message: "Rate limit reached for requests", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 429, + isRetryable: true, + data: { + error: { + message: "Rate limit reached for requests", + type: "requests", + param: null, + code: "rate_limit_exceeded", + }, + }, + responseHeaders: { + "retry-after": delaySeconds.toString(), + "x-request-id": "req_generatetext123", + }, + }); + } + + // Third call succeeds + return { + content: [{ type: "text" as const, text: "Generated after retry" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 5, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 15, + }, + warnings: [], + }; + }, + }); + + const result = await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test generateText rate limit retry", + }); + + expect(result.text).toBe("Generated after retry"); + expect(callCount).toBe(3); // Should have retried twice + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP request patterns for retries + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCalls = mockHttpRequests.filter( + (req) => req.type === "createRun", + ); + const updateRunCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun", + ); + + // With 3 doGenerate attempts (2 failures + 1 success), we should see: + // 4 createRun calls: 1 parent + 3 child doGenerate attempts + // 4 updateRun calls: 2 child failures + 1 child success + 1 parent success + expect(createRunCalls.length).toBe(4); + expect(updateRunCalls.length).toBe(4); + + // Verify the success/error pattern: 1 parent success + 2 child errors + 1 child success + const successUpdateCalls = mockHttpRequests.filter( + (req) => + req.type === "updateRun" && !req.body.error && req.body.outputs, + ); + const errorUpdateCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(successUpdateCalls.length).toBe(2); // 1 parent generateText + 1 child doGenerate success + expect(errorUpdateCalls.length).toBe(2); // 2 child doGenerate failures + + // Find the parent generateText success (has content array) + const parentSuccessCall = successUpdateCalls.find( + (req) => req.body.outputs.content?.[0]?.text, + ); + expect(parentSuccessCall).toBeDefined(); + expect(parentSuccessCall.body.outputs.content[0].text).toBe( + "Generated after retry", + ); + + // Verify the final successful call has usage metadata + expect( + parentSuccessCall.body.extra?.metadata?.usage_metadata, + ).toBeDefined(); + expect( + parentSuccessCall.body.extra.metadata.usage_metadata.total_tokens, + ).toBe(15); + }); + + it("should handle stream cancellation with abortController", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const abortController = new AbortController(); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "abort-test-model", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Lorem" }, + { type: "text-delta", id: "text-1", delta: " ipsum" }, + { type: "text-delta", id: "text-1", delta: " dolor" }, + { type: "text-delta", id: "text-1", delta: " sit" }, + { type: "text-delta", id: "text-1", delta: " amet" }, + { type: "text-delta", id: "text-1", delta: " consectetur" }, + { type: "text-delta", id: "text-1", delta: " adipiscing" }, + { type: "text-delta", id: "text-1", delta: " elit" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 13, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Tell me a lorem ipsum poem", + abortSignal: abortController.signal, + }); + + // Consume stream and abort after a few chunks + let chunkCount = 0; + let fullText = ""; + + for await (const textPart of result.textStream) { + fullText += textPart; + chunkCount++; + + if (chunkCount > 5) { + abortController.abort(); + break; + } + } + + expect(chunkCount).toBeGreaterThan(5); + expect(fullText).toBeTruthy(); + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made even with cancellation + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toHaveProperty( + "prompt", + "Tell me a lorem ipsum poem", + ); + }); + }); + + describe("config resolution", () => { + it("should merge base and runtime configs correctly", async () => { + const baseConfig = { + name: "base-tracer", + metadata: { baseField: "base-value", shared: "base" }, + tags: ["base-tag"], + badClient: "bad", + }; + + const runtimeConfig = createLangSmithProviderOptions({ + name: "runtime-tracer", + metadata: { runtimeField: "runtime-value", shared: "runtime" }, + tags: ["runtime-tag"], + client: mockClient as any, + }); + + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + baseConfig, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "config-merge-test", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test config merging", + providerOptions: { + langsmith: runtimeConfig, + }, + }); + + // Verify merged config was used in trace + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.name).toBe("runtime-tracer"); // Runtime overrides base + expect(createRunCall.body.extra.metadata).toMatchObject({ + baseField: "base-value", + runtimeField: "runtime-value", + shared: "runtime", // Runtime wins for conflicts + ai_sdk_method: "ai.generateText", + }); + expect(createRunCall.body.tags).toEqual(["runtime-tag"]); // Runtime overrides base + }); + + it("should handle process inputs and output options", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "config-provider-test", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + const lsConfig = createLangSmithProviderOptions({ + processInputs: (inputs) => { + const { messages, prompt, ...rest } = inputs; + return { + ...rest, + messages: messages?.map((message) => ({ + ...message, + content: "REDACTED", + })), + prompt: "REDACTED", + }; + }, + processOutputs: (outputs) => { + // @ts-expect-error - outputs is wrapped one level deep + outputs.content; + const originalTracedMessage = { ...outputs.outputs }; + return { + ...originalTracedMessage, + content: "REDACTED", + }; + }, + processChildLLMRunInputs: (inputs) => { + const { prompt } = inputs; + return { + messages: prompt.map((message) => ({ + ...message, + content: "REDACTED CHILD INPUTS", + })), + }; + }, + processChildLLMRunOutputs: (outputs) => { + // @ts-expect-error - no wrapping for child outputs + outputs.outputs; + return { + ...outputs, + content: "REDACTED CHILD OUTPUTS", + role: "assistant", + }; + }, + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + messages: [ + { + role: "user", + content: "hello there!", + }, + ], + providerOptions: { + langsmith: lsConfig, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests).toHaveLength(4); + // Verify merged config was used in trace + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toMatchObject({ + prompt: "REDACTED", + messages: [ + { + content: "REDACTED", + }, + ], + }); + const createUpdateCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + expect(createUpdateCall.body.outputs).toMatchObject({ + content: "REDACTED", + }); + const createLLMRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(createLLMRunCall.body.inputs).toMatchObject({ + messages: [ + { + role: "user", + content: "REDACTED CHILD INPUTS", + }, + ], + }); + const updateLLMRunCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(updateLLMRunCall.body.outputs).toMatchObject({ + content: "REDACTED CHILD OUTPUTS", + }); + }); + }); + + describe("model display name handling", () => { + it("should handle model with config provider", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + const modelWithConfig = new MockLanguageModelV3({ + modelId: "custom-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: { + total: 5, + noCache: 5, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 3, text: 3, reasoning: 0 }, + totalTokens: 8, + }, + warnings: [], + }), + }); + + // Add config property to simulate provider info + (modelWithConfig as any).config = { provider: "custom-provider" }; + + await wrappedMethods.generateText({ + model: modelWithConfig, + prompt: "Test config", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify the trace was created with proper model name + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence and that first run uses the custom provider name + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[0].body.name).toBe("custom-provider"); + }); + }); + + describe("output handling", () => { + it("should preserve output as structured object in outputs", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "experimental-output-test", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"city":"Prague","temperature":15,"unit":"celsius","conditions":"sunny"}', + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 20, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 10, + cacheWrite: 10, + }, + outputTokens: { total: 20, text: 20, reasoning: 0 }, + totalTokens: 30, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "What's the weather?", + output: ai.Output.object({ + schema: z.object({ + city: z.string(), + temperature: z.number(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }), + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Find the updateRun call for generateText (parent run) + const updateGenerateTextCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.generateText", + ); + + expect(updateGenerateTextCall).toBeDefined(); + + // Verify outputs contain the structured object at top level (like generateObject) + expect(updateGenerateTextCall.body.outputs).toMatchObject({ + city: "Prague", + temperature: 15, + unit: "celsius", + conditions: "sunny", + }); + + // Verify it's NOT stringified + const outputsStr = JSON.stringify(updateGenerateTextCall.body.outputs); + expect(outputsStr).not.toContain("[object Object]"); + expect(outputsStr).not.toContain('\\"city\\"'); // Should not be double-escaped + }); + + it("should preserve output as structured object in streamText", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-experimental-output-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: + '{"city":"Berlin","temperature":10,"unit":"celsius","conditions":"cloudy"}', + }, + { type: "text-end", id: "text-1" }, + { + type: "finish" as const, + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 10, + cacheWrite: 10, + }, + outputTokens: { total: 20, text: 20, reasoning: 0 }, + totalTokens: 30, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "What's the weather?", + output: ai.Output.object({ + schema: z.object({ + city: z.string(), + temperature: z.number(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }), + }), + }); + + await result.consumeStream(); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Find the updateRun call for streamText (parent run) + const updateStreamTextCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.streamText", + ); + + expect(updateStreamTextCall).toBeDefined(); + + // Verify outputs contain the structured object at top level (parsed from JSON text) + expect(updateStreamTextCall.body.outputs).toMatchObject({ + city: "Berlin", + temperature: 10, + unit: "celsius", + conditions: "cloudy", + }); + + // Verify it's NOT stringified + const outputsStr = JSON.stringify(updateStreamTextCall.body.outputs); + expect(outputsStr).not.toContain("[object Object]"); + expect(outputsStr).not.toContain('\\"city\\"'); // Should not be double-escaped + }); + }); + + describe("AI SDK 6 token tracking", () => { + it("should track reasoning tokens in AI SDK 6", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "reasoning-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 25, text: 15, reasoning: 10 }, + totalTokens: 35, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the LLM run (child run) + const llmRunUpdate = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + + expect(llmRunUpdate).toBeDefined(); + expect(llmRunUpdate.body.extra.metadata.usage_metadata).toMatchObject({ + input_tokens: 10, + output_tokens: 25, + total_tokens: 35, + }); + + // Verify reasoning tokens are tracked + expect( + llmRunUpdate.body.extra.metadata.usage_metadata.output_token_details + .reasoning, + ).toBe(10); + }); + + it("should track cache tokens in AI SDK 6", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "cache-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 100, + noCache: 50, + cacheRead: 30, + cacheWrite: 20, + }, + outputTokens: { total: 10, text: 10, reasoning: 0 }, + totalTokens: 110, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the LLM run + const llmRunUpdate = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + + expect(llmRunUpdate).toBeDefined(); + expect(llmRunUpdate.body.extra.metadata.usage_metadata).toMatchObject({ + input_tokens: 100, + output_tokens: 10, + total_tokens: 110, + }); + + // Verify cache tokens are tracked + const inputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.input_token_details; + expect(inputTokenDetails.cache_read).toBe(30); + expect(inputTokenDetails.cache_creation).toBe(20); + }); + + it("should track OpenAI flex service tier tokens", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "flex-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 100, + noCache: 80, + cacheRead: 20, + cacheWrite: 0, + }, + outputTokens: { total: 30, text: 25, reasoning: 5 }, + totalTokens: 130, + }, + providerMetadata: { + openai: { + serviceTier: "flex", + }, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the LLM run + const llmRunUpdate = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + + expect(llmRunUpdate).toBeDefined(); + expect(llmRunUpdate.body.extra.metadata.usage_metadata).toMatchObject({ + input_tokens: 100, + output_tokens: 30, + total_tokens: 130, + }); + + // Verify service tier tokens are tracked with proper prefixes + const inputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.input_token_details; + const outputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.output_token_details; + + // Input: flex tier tokens (total - cached) + expect(inputTokenDetails.flex).toBe(80); // 100 - 20 + expect(inputTokenDetails.flex_cache_read).toBe(20); + + // Output: flex tier tokens (total - reasoning) and reasoning tokens + expect(outputTokenDetails.flex).toBe(25); // 30 - 5 + expect(outputTokenDetails.flex_reasoning).toBe(5); + }); + + it("should track OpenAI priority service tier tokens", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "priority-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { + total: 50, + noCache: 50, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 40, text: 30, reasoning: 10 }, + totalTokens: 90, + }, + providerMetadata: { + openai: { + serviceTier: "priority", + }, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the LLM run + const llmRunUpdate = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + + expect(llmRunUpdate).toBeDefined(); + + // Verify service tier tokens are tracked with priority prefix + const inputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.input_token_details; + const outputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.output_token_details; + + expect(inputTokenDetails.priority).toBe(50); + expect(outputTokenDetails.priority).toBe(30); // 40 - 10 + expect(outputTokenDetails.priority_reasoning).toBe(10); + }); + + it("should handle streaming with cache and reasoning tokens", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-cache-reasoning-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish" as const, + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 200, + noCache: 100, + cacheRead: 80, + cacheWrite: 20, + }, + outputTokens: { total: 50, text: 40, reasoning: 10 }, + totalTokens: 250, + }, + providerMetadata: { + openai: { + serviceTier: "flex", + }, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await result.consumeStream(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Find the LLM run + const llmRunUpdate = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + + expect(llmRunUpdate).toBeDefined(); + expect(llmRunUpdate.body.extra.metadata.usage_metadata).toMatchObject({ + input_tokens: 200, + output_tokens: 50, + total_tokens: 250, + }); + + // Verify all token details are tracked + const inputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.input_token_details; + const outputTokenDetails = + llmRunUpdate.body.extra.metadata.usage_metadata.output_token_details; + + expect(inputTokenDetails.flex_cache_read).toBe(80); + expect(inputTokenDetails.cache_creation).toBe(20); + expect(inputTokenDetails.flex).toBe(120); // 200 - 80 + + expect(outputTokenDetails.flex_reasoning).toBe(10); + expect(outputTokenDetails.flex).toBe(40); // 50 - 10 + }); + }); + + describe("reasoning-delta and content block aggregation", () => { + const finishChunk = (usage = {}) => ({ + type: "finish" as const, + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 10, + ...usage, + }, + }); + + it.each([ + { + scenario: "text-only flattens to string", + chunks: [ + { type: "text-start", id: "t-1" }, + { type: "text-delta", id: "t-1", delta: "Just text" }, + { type: "text-delta", id: "t-1", delta: ", nothing else" }, + { type: "text-end", id: "t-1" }, + finishChunk(), + ], + expectedContent: "Just text, nothing else", + }, + { + scenario: "reasoning + text stays as block array", + chunks: [ + { type: "reasoning-start", id: "r-1" }, + { type: "reasoning-delta", id: "r-1", delta: "Let me think" }, + { type: "reasoning-delta", id: "r-1", delta: " about this..." }, + { type: "reasoning-end", id: "r-1" }, + { type: "text-start", id: "t-1" }, + { type: "text-delta", id: "t-1", delta: "The answer is 42" }, + { type: "text-end", id: "t-1" }, + finishChunk(), + ], + expectedContent: [ + { type: "reasoning", reasoning: "Let me think about this..." }, + { type: "text", text: "The answer is 42" }, + ], + }, + { + scenario: "reasoning-only stays as block array", + chunks: [ + { type: "reasoning-start", id: "r-1" }, + { + type: "reasoning-delta", + id: "r-1", + delta: "Only reasoning, no text output", + }, + { type: "reasoning-end", id: "r-1" }, + finishChunk(), + ], + expectedContent: [ + { + type: "reasoning", + reasoning: "Only reasoning, no text output", + }, + ], + }, + { + scenario: "interleaved reasoning and text blocks", + chunks: [ + { type: "reasoning-start", id: "r-1" }, + { type: "reasoning-delta", id: "r-1", delta: "First thought" }, + { type: "reasoning-end", id: "r-1" }, + { type: "text-start", id: "t-1" }, + { type: "text-delta", id: "t-1", delta: "Step one" }, + { type: "text-end", id: "t-1" }, + { type: "reasoning-start", id: "r-2" }, + { type: "reasoning-delta", id: "r-2", delta: "Second thought" }, + { type: "reasoning-end", id: "r-2" }, + { type: "text-start", id: "t-2" }, + { type: "text-delta", id: "t-2", delta: "Step two" }, + { type: "text-end", id: "t-2" }, + finishChunk(), + ], + expectedContent: [ + { type: "reasoning", reasoning: "First thought" }, + { type: "text", text: "Step one" }, + { type: "reasoning", reasoning: "Second thought" }, + { type: "text", text: "Step two" }, + ], + }, + { + scenario: "consecutive reasoning deltas merge into one block", + chunks: [ + { type: "reasoning-start", id: "r-1" }, + { type: "reasoning-delta", id: "r-1", delta: "Part A, " }, + { type: "reasoning-delta", id: "r-1", delta: "Part B, " }, + { type: "reasoning-delta", id: "r-1", delta: "Part C" }, + { type: "reasoning-end", id: "r-1" }, + { type: "text-start", id: "t-1" }, + { type: "text-delta", id: "t-1", delta: "Done" }, + { type: "text-end", id: "t-1" }, + finishChunk(), + ], + expectedContent: [ + { type: "reasoning", reasoning: "Part A, Part B, Part C" }, + { type: "text", text: "Done" }, + ], + }, + ])("should handle $scenario", async ({ chunks, expectedContent }) => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "content-block-test", + doStream: async () => ({ + stream: simulateReadableStream({ chunks: chunks as any }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "test", + }); + + await result.consumeStream(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const doStreamUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + + expect(doStreamUpdateRun).toBeDefined(); + expect(doStreamUpdateRun.body.outputs.content).toEqual(expectedContent); + }); + + it("should preserve providerMetadata extras on reasoning blocks", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "reasoning-extras-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "reasoning-start", id: "r-1" }, + { + type: "reasoning-delta", + id: "r-1", + delta: "Deep thought", + providerMetadata: { openai: { someFlag: true } }, + }, + { type: "reasoning-end", id: "r-1" }, + { type: "text-start", id: "t-1" }, + { type: "text-delta", id: "t-1", delta: "Result" }, + { type: "text-end", id: "t-1" }, + finishChunk(), + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Think with extras", + }); + + await result.consumeStream(); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const doStreamUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + + expect(doStreamUpdateRun).toBeDefined(); + expect(doStreamUpdateRun.body.outputs.content[0]).toMatchObject({ + type: "reasoning", + reasoning: "Deep thought", + extras: { openai: { someFlag: true } }, + }); + }); + }); + + describe("ToolLoopAgent tracing", () => { + it("should wrap ToolLoopAgent class correctly", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { client: mockClient as any }, + ); + + // Verify ToolLoopAgent was wrapped + expect(wrappedMethods.ToolLoopAgent).toBeDefined(); + expect(wrappedMethods.ToolLoopAgent).not.toBe(ai.ToolLoopAgent); + }); + + it("should trace ToolLoopAgent.generate() without tools", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "no-tools-test", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: "Direct response without tools", + }, + ], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 20, + completionTokens: 8, + inputTokens: { + total: 20, + noCache: 20, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 8, text: 8, reasoning: 0 }, + totalTokens: 28, + }, + warnings: [], + }), + }); + + const agent = new wrappedMethods.ToolLoopAgent({ + model: mockLangModel, + tools: {}, + }); + + const result = await agent.generate({ + prompt: "Just answer directly", + }); + + expect(result.text).toBe("Direct response without tools"); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Should have agent run and LLM run, but no tool runs + const agentRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.ToolLoopAgent.generate", + ); + expect(agentRun).toBeDefined(); + expect(agentRun.body.name).toBe("ToolLoopAgent"); + expect(agentRun.body.inputs).toHaveProperty( + "prompt", + "Just answer directly", + ); + + const llmRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(llmRun).toBeDefined(); + + const toolRuns = mockHttpRequests.filter( + (req) => req.type === "createRun" && req.body.run_type === "tool", + ); + expect(toolRuns.length).toBe(0); + + // Verify agent update run has response_metadata with steps + const agentUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === + "ai.ToolLoopAgent.generate", + ); + expect(agentUpdateRun).toBeDefined(); + expect(agentUpdateRun.body.outputs.response_metadata).toBeDefined(); + expect(agentUpdateRun.body.outputs.response_metadata.steps).toBeDefined(); + }); + + it("should trace ToolLoopAgent.stream() without tools", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "stream-no-tools-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Stream response" }, + { type: "text-end", id: "text-1" }, + { + type: "finish" as const, + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 15, + noCache: 15, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 20, + }, + }, + ], + }), + }), + }); + + const agent = new wrappedMethods.ToolLoopAgent({ + model: mockLangModel, + tools: {}, + }); + + const result = await agent.stream({ + prompt: "Stream test", + }); + + // Consume the stream + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Stream response"); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify agent stream run was created + const agentRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.ToolLoopAgent.stream", + ); + expect(agentRun).toBeDefined(); + expect(agentRun.body.name).toBe("ToolLoopAgent"); + + const agentUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.ToolLoopAgent.stream", + ); + expect(agentUpdateRun).toBeDefined(); + expect(agentUpdateRun.body.outputs.response_metadata.steps).toBeDefined(); + }); + + it("should handle ToolLoopAgent with custom processInputs/processOutputs", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "agent-process-test", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Final answer" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 30, + completionTokens: 10, + inputTokens: { + total: 30, + noCache: 30, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 10, text: 10, reasoning: 0 }, + totalTokens: 40, + }, + warnings: [], + }), + }); + + const lsConfig = createLangSmithProviderOptions({ + processInputs: (inputs) => ({ + ...inputs, + prompt: "REDACTED_PROMPT", + }), + processOutputs: (outputs) => ({ + ...outputs.outputs, + content: [{ type: "text", text: "REDACTED_OUTPUT" }], + }), + processChildLLMRunInputs: (inputs) => ({ + messages: inputs.prompt.map((msg: any) => ({ + ...msg, + content: "REDACTED_CHILD_INPUT", + })), + }), + processChildLLMRunOutputs: () => ({ + content: "REDACTED_CHILD_OUTPUT", + role: "assistant", + }), + }); + + const agent = new wrappedMethods.ToolLoopAgent({ + model: mockLangModel, + tools: {}, + providerOptions: { langsmith: lsConfig }, + }); + + await agent.generate({ + prompt: "Secret prompt that should be redacted", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify parent agent run has redacted inputs/outputs + const agentCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.ToolLoopAgent.generate", + ); + expect(agentCreateRun).toBeDefined(); + expect(agentCreateRun.body.inputs.prompt).toBe("REDACTED_PROMPT"); + + const agentUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === + "ai.ToolLoopAgent.generate", + ); + expect(agentUpdateRun).toBeDefined(); + expect(agentUpdateRun.body.outputs.content[0].text).toBe( + "REDACTED_OUTPUT", + ); + + // Verify child LLM run has redacted inputs/outputs + const llmCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(llmCreateRun).toBeDefined(); + expect(llmCreateRun.body.inputs.messages[0].content).toBe( + "REDACTED_CHILD_INPUT", + ); + + const llmUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + expect(llmUpdateRun).toBeDefined(); + expect(llmUpdateRun.body.outputs.content).toBe("REDACTED_CHILD_OUTPUT"); + }); + + it("should handle ToolLoopAgent with metadata config", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { + client: mockClient as any, + metadata: { testKey: "testValue" }, + tags: ["agent-test"], + }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "metadata-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 10, + completionTokens: 5, + inputTokens: { + total: 10, + noCache: 10, + cacheRead: 0, + cacheWrite: 0, + }, + outputTokens: { total: 5, text: 5, reasoning: 0 }, + totalTokens: 15, + }, + warnings: [], + }), + }); + + const agent = new wrappedMethods.ToolLoopAgent({ + model: mockLangModel, + tools: {}, + }); + + await agent.generate({ prompt: "Test" }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + const agentRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.ToolLoopAgent.generate", + ); + expect(agentRun).toBeDefined(); + expect(agentRun.body.extra.metadata.testKey).toBe("testValue"); + expect(agentRun.body.tags).toContain("agent-test"); + }); + + it("should trace LLM child runs (doGenerate/doStream) within ToolLoopAgent", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + ToolLoopAgent: ai.ToolLoopAgent, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV3({ + modelId: "llm-tracing-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "LLM response" }], + finishReason: { unified: "stop" as const, raw: "stop" }, + usage: { + promptTokens: 25, + completionTokens: 12, + inputTokens: { + total: 25, + noCache: 20, + cacheRead: 5, + cacheWrite: 0, + }, + outputTokens: { total: 12, text: 10, reasoning: 2 }, + totalTokens: 37, + }, + warnings: [], + }), + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: "Streamed LLM response", + }, + { type: "text-end", id: "text-1" }, + { + type: "finish" as const, + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { + total: 30, + noCache: 25, + cacheRead: 5, + cacheWrite: 0, + }, + outputTokens: { total: 15, text: 13, reasoning: 2 }, + totalTokens: 45, + }, + }, + ], + }), + }), + }); + + const agent = new wrappedMethods.ToolLoopAgent({ + model: mockLangModel, + tools: {}, + }); + + // Test generate() - should create doGenerate child run + await agent.generate({ prompt: "Test generate" }); + await new Promise((resolve) => setTimeout(resolve, 1)); + + const doGenerateCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(doGenerateCreateRun).toBeDefined(); + expect(doGenerateCreateRun.body.extra.metadata.ls_model_name).toBe( + "llm-tracing-test-model", + ); + + const doGenerateUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doGenerate", + ); + expect(doGenerateUpdateRun).toBeDefined(); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata, + ).toBeDefined(); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata.input_tokens, + ).toBe(25); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata.output_tokens, + ).toBe(12); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata.total_tokens, + ).toBe(37); + // Verify token details + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata + .input_token_details, + ).toBeDefined(); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata + .input_token_details.cache_read, + ).toBe(5); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata + .output_token_details, + ).toBeDefined(); + expect( + doGenerateUpdateRun.body.extra.metadata.usage_metadata + .output_token_details.reasoning, + ).toBe(2); + + // Clear requests for stream test + mockHttpRequests.length = 0; + + // Test stream() - should create doStream child run + const streamResult = await agent.stream({ prompt: "Test stream" }); + let streamedText = ""; + for await (const chunk of streamResult.textStream) { + streamedText += chunk; + } + expect(streamedText).toBe("Streamed LLM response"); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + const doStreamCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doStream", + ); + expect(doStreamCreateRun).toBeDefined(); + expect(doStreamCreateRun.body.extra.metadata.ls_model_name).toBe( + "llm-tracing-test-model", + ); + + const doStreamUpdateRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.doStream", + ); + expect(doStreamUpdateRun).toBeDefined(); + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata, + ).toBeDefined(); + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata.input_tokens, + ).toBe(30); + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata.output_tokens, + ).toBe(15); + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata.total_tokens, + ).toBe(45); + // Verify token details for streaming + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata.input_token_details + .cache_read, + ).toBe(5); + expect( + doStreamUpdateRun.body.extra.metadata.usage_metadata + .output_token_details.reasoning, + ).toBe(2); + }); + }); + + describe("ls_agent_type metadata", () => { + it("should set ls_agent_type to 'root' at top level and 'subagent' inside a tool", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + // Top-level call should be "root" + await wrappedMethods.generateText({ + model: standardMockedModel, + prompt: "Root prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + const rootRun = mockHttpRequests.find( + (r: any) => + r.type === "createRun" && + r.body.extra?.metadata?.ai_sdk_method === "ai.generateText", + ); + expect(rootRun).toBeDefined(); + expect(rootRun.body.extra.metadata.ls_agent_type).toBe("root"); + + // Reset for next call + mockHttpRequests.length = 0; + + // Call inside a tool should be "subagent" + const toolFunc = traceable( + async () => { + await wrappedMethods.generateText({ + model: standardMockedModel, + prompt: "Subagent prompt", + }); + return "tool result"; + }, + { name: "my_tool", run_type: "tool", client: mockClient as any }, + ); + + await toolFunc(); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + const subagentRun = mockHttpRequests.find( + (r: any) => + r.type === "createRun" && + r.body.extra?.metadata?.ai_sdk_method === "ai.generateText", + ); + expect(subagentRun).toBeDefined(); + expect(subagentRun.body.extra.metadata.ls_agent_type).toBe("subagent"); + }); + }); +}); diff --git a/src/lib/tests/vercel/wrapper/openai.int.test.ts b/src/lib/tests/vercel/wrapper/openai.int.test.ts new file mode 100644 index 0000000..af4a0f6 --- /dev/null +++ b/src/lib/tests/vercel/wrapper/openai.int.test.ts @@ -0,0 +1,734 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { openai } from "@ai-sdk/openai"; +import * as ai from "ai"; +import z from "zod"; +import { v4 } from "../../../utils/uuid/src/index.js"; +import * as fs from "fs/promises"; +import { fileURLToPath } from "url"; +import path from "path"; + +import { Client } from "../../../index.js"; +import { + createLangSmithProviderOptions, + wrapAISDK, +} from "../../../experimental/vercel/index.js"; +import { generateLongContext, waitUntilRunFound } from "../../utils.js"; +import { mockClient } from "../../utils/mock_client.js"; +import { traceable } from "../../../traceable.js"; +import { requiresProvider, requires } from "../../utils/markers.js"; +const { test, it } = requiresProvider; + +const { tool, stepCountIs } = ai; + +const { + generateText, + streamText, + generateObject, + streamObject, + ToolLoopAgent, +} = wrapAISDK(ai); + +test("wrap generateText", async () => { + const result = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test("wrap generateText with tool class", async () => { + class MyTool { + inputSchema: z.ZodSchema; + description: string; + + constructor(inputSchema: z.ZodSchema, description: string) { + this.inputSchema = inputSchema; + this.description = description; + } + + async execute() { + return this.helperMethod(); + } + + helperMethod() { + return `User has the following orders: 1`; + } + } + + const result = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: { + listOrders: new MyTool( + z.object({ userId: z.string() }), + "list all orders", + ), + }, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test("wrap generateText with flex service tier", async () => { + const { client, callSpy } = mockClient(); + + const result = await generateText({ + model: openai("gpt-5-mini"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + providerOptions: { + openai: { + serviceTier: "flex", + }, + langsmith: createLangSmithProviderOptions({ + client, + }), + }, + }); + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); + await client.awaitPendingTraceBatches(); + const patchBodies = await Promise.all( + callSpy.mock.calls + .filter((call: any) => call[1]!.method === "PATCH") + .map((call: any) => new Response(call[1]!.body).json()), + ); + const childRunPatchBodies = patchBodies.filter( + (body) => body.parent_run_id != null, + ); + + expect(childRunPatchBodies.length).toBeGreaterThanOrEqual(1); + + const llmChildRun = childRunPatchBodies.find( + (body) => body.extra?.metadata?.usage_metadata, + ); + expect(llmChildRun).toBeDefined(); + + const usageMetadata = llmChildRun!.extra.metadata.usage_metadata; + expect(usageMetadata.input_token_details.flex).toBeGreaterThan(1); + expect(usageMetadata.input_token_details.flex).toEqual( + usageMetadata.input_tokens, + ); + expect(usageMetadata.output_token_details.flex).toBeGreaterThan(1); + expect( + usageMetadata.output_token_details.flex + + usageMetadata.output_token_details.flex_reasoning, + ).toEqual(usageMetadata.output_tokens); +}); + +test("wrap streamText", async () => { + const result = streamText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + let total = ""; + for await (const chunk of result.textStream) { + total += chunk; + } + expect(total).toBeDefined(); + expect(total.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test("wrap streamText with service tier", async () => { + const { client, callSpy } = mockClient(); + + const result = streamText({ + model: openai("gpt-5-mini"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + providerOptions: { + openai: { + serviceTier: "flex", + }, + langsmith: createLangSmithProviderOptions({ + client, + }), + }, + }); + await result.consumeStream(); + const patchBodies = await Promise.all( + callSpy.mock.calls + .filter((call: any) => call[1]!.method === "PATCH") + .map((call: any) => new Response(call[1]!.body).json()), + ); + const childRunPatchBodies = patchBodies.filter( + (body) => body.parent_run_id != null, + ); + + expect(childRunPatchBodies.length).toBeGreaterThanOrEqual(1); + + const llmChildRun = childRunPatchBodies.find( + (body) => body.extra?.metadata?.usage_metadata, + ); + expect(llmChildRun).toBeDefined(); + + const usageMetadata = llmChildRun!.extra.metadata.usage_metadata; + expect(usageMetadata.input_token_details.flex).toBeGreaterThan(1); + expect(usageMetadata.input_token_details.flex).toEqual( + usageMetadata.input_tokens, + ); + expect(usageMetadata.output_token_details.flex).toBeGreaterThan(1); + expect( + usageMetadata.output_token_details.flex + + usageMetadata.output_token_details.flex_reasoning, + ).toEqual(usageMetadata.output_tokens); +}); + +test("wrap generateText with an output schema", async () => { + const schema = z.object({ + color: z.string(), + }); + const output = ai.Output.object({ + schema, + }); + const result = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + output, + }); + expect(result.output).toBeDefined(); + expect(schema.parse(result.output)).toBeDefined(); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test.skip("wrap generateObject (deprecated)", async () => { + const schema = z.object({ + color: z.string(), + }); + const result = await generateObject({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + schema, + }); + expect(result.object).toBeDefined(); + expect(schema.parse(result.object)).toBeDefined(); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test("wrap streamText with an output schema", async () => { + const schema = z.object({ + color: z.string(), + }); + const output = ai.Output.object({ + schema, + }); + const result = streamText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + output, + }); + const chunks = []; + for await (const chunk of result.partialOutputStream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(schema.parse(chunks.at(-1))).toBeDefined(); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +// Deprecated in AI SDK v6 +test.skip("wrap streamObject", async () => { + const schema = z.object({ + color: z.string(), + }); + const result = streamObject({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + schema, + }); + const chunks = []; + for await (const chunk of result.partialObjectStream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(schema.parse(chunks.at(-1))).toBeDefined(); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +requires("require_provider", "require_clickhouse").test( + "can set run id", + async () => { + const runId = v4(); + const client = new Client(); + const { generateText } = wrapAISDK(ai, { id: runId }); + await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What color is the sky in one word?", + }, + ], + }); + await waitUntilRunFound(client, runId); + const run = await client.readRun(runId); + expect(run.id).toBe(runId); + }, +); + +test("should reuse tool def without double wrapping tool traces", async () => { + const toolDef = { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }; + const result = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: toolDef, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); + const result2 = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: "What are my orders? My user ID is 123. Always use tools.", + }, + ], + tools: toolDef, + stopWhen: stepCountIs(10), + providerOptions: { openai: { store: false } }, + }); + expect(result2.text).toBeDefined(); + expect(result2.text.length).toBeGreaterThan(0); + expect(result2.usage).toBeDefined(); + expect(result2.providerMetadata).toBeDefined(); +}); + +// Skipped: data: URL in image part triggers SSRF validation bug in +// @ai-sdk/provider-utils@4.0.21. Fix merged upstream (vercel/ai#13376) +// but not yet released. Re-enable once a patched version is available. +test.skip("image and file data normalization", async () => { + const pathname = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", + "test_data", + "parrot-icon.png", + ); + const imgBuffer = await fs.readFile(pathname); + const imgArrayBuffer = imgBuffer.buffer.slice( + imgBuffer.byteOffset, + imgBuffer.byteOffset + imgBuffer.byteLength, + ) as ArrayBuffer; + const imgBase64 = imgBuffer.toString("base64"); + const imgUrl = "https://smith.langchain.com/og_image.png"; + const imgDataUrl = `data:image/png;base64,${imgBase64}`; + const imgUrlObject = new URL("https://smith.langchain.com/og_image.png"); + + const result = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Analyze all these images and files:" }, + { type: "image", image: imgBuffer }, // Node.js Buffer + { type: "image", image: imgArrayBuffer }, // ArrayBuffer + { type: "image", image: imgBase64 }, // Base64 string + { type: "image", image: imgUrl }, // HTTP URL string + { type: "image", image: imgDataUrl }, // Data URL + { type: "image", image: imgUrlObject }, // URL object + { + type: "file", + data: imgBuffer, + mediaType: "image/png", + filename: "test.png", + }, // File with Buffer data + ], + }, + ], + }); + expect(result.text).toBeDefined(); + expect(result.text.length).toBeGreaterThan(0); + expect(result.usage).toBeDefined(); + expect(result.providerMetadata).toBeDefined(); +}); + +test("process inputs and outputs", async () => { + const lsConfig = createLangSmithProviderOptions({ + processInputs: (inputs) => { + const { messages } = inputs; + return { + messages: messages?.map((message) => ({ + providerMetadata: message.providerOptions, + role: "assistant", + content: "REDACTED", + })), + prompt: "REDACTED", + }; + }, + processOutputs: (outputs) => { + return { + providerMetadata: outputs.outputs.providerMetadata, + role: "assistant", + content: "REDACTED", + }; + }, + processChildLLMRunInputs: (inputs) => { + const { prompt } = inputs; + return { + messages: prompt.map((message) => ({ + ...message, + content: "REDACTED CHILD INPUTS", + })), + }; + }, + processChildLLMRunOutputs: (outputs) => { + return { + ...outputs, + content: "REDACTED CHILD OUTPUTS", + role: "assistant", + }; + }, + }); + const { text } = await generateText({ + model: openai("gpt-5-nano"), + prompt: "What is the capital of France?", + providerOptions: { + langsmith: lsConfig, + }, + }); + expect(text).not.toContain("REDACTED"); +}); + +test("generateText with output should display as structured object in LangSmith", async () => { + const outputSchema = z.object({ + city: z.string(), + temperature: z.number().nullable(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }); + + const { generateText: wrappedGenerateText } = wrapAISDK(ai); + + const result = await wrappedGenerateText({ + model: openai("gpt-5-nano"), + prompt: "What's the weather in Prague? Return a structured response.", + output: ai.Output.object({ schema: outputSchema }), + }); + + // Verify the output is returned correctly and can be parsed + expect(result.output).toBeDefined(); + const parsedOutput = outputSchema.parse(result.output); + expect(parsedOutput.city).toBeDefined(); + expect(parsedOutput.temperature).toBeDefined(); + expect(parsedOutput.unit).toBeDefined(); + expect(parsedOutput.conditions).toBeDefined(); +}); + +test("streamText with output should display as structured object in LangSmith", async () => { + const outputSchema = z.object({ + city: z.string(), + temperature: z.number().nullable(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }); + + const { streamText: wrappedStreamText } = wrapAISDK(ai); + + const result = wrappedStreamText({ + model: openai("gpt-5-nano"), + prompt: "What's the weather in Paris? Return a structured response.", + output: ai.Output.object({ + schema: outputSchema, + }), + }); + + const chunks = []; + // Consume the stream + for await (const chunk of result.experimental_partialOutputStream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(outputSchema.parse(chunks.at(-1))).toBeDefined(); +}); + +it.skip("openai cache with large prompt for automatic caching", async () => { + const meta = v4(); + const client = new Client(); + const aiSDKResponses: unknown[] = []; + + // Create a large prompt (>1024 tokens) to trigger OpenAI's automatic prompt caching + const largeProgrammingContext = generateLongContext(); + + const wrapper = traceable( + async () => { + // First call - should create cache due to large prompt (>1024 tokens) + try { + const res1 = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "system", + content: largeProgrammingContext, + }, + { + role: "user", + content: + "What are the top 3 memory optimization strategies you would recommend for this Java service?", + }, + ], + }); + aiSDKResponses.push(res1); + console.log("Cache create response:", res1.usage); + } catch (error) { + console.error("Cache create error:", error); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Second call - should read from cache with same large context + try { + const res2 = await generateText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "system", + content: largeProgrammingContext, + }, + { + role: "user", + content: + "How would you redesign the database access pattern to reduce connection pool pressure?", + }, + ], + }); + aiSDKResponses.push(res2); + console.log("Cache read response:", res2.usage); + } catch (error) { + console.error("Cache read error:", error); + } + + return "OpenAI cache test completed"; + }, + { + name: "OpenAI Cache Test Wrapper", + metadata: { testKey: meta }, + client, + }, + ); + + await wrapper(); + + await client.awaitPendingTraceBatches(); +}); + +it.skip("openai cache with streamText", async () => { + const meta = v4(); + const client = new Client(); + const aiSDKResponses: unknown[] = []; + + // Create a large prompt (>1024 tokens) to trigger OpenAI's automatic prompt caching + const largeProgrammingContext = generateLongContext(); + + const wrapper = traceable( + async () => { + // First call - should create cache due to large prompt (>1024 tokens) + try { + const { textStream } = streamText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "system", + content: largeProgrammingContext, + }, + { + role: "user", + content: + "What are the top 3 memory optimization strategies you would recommend for this Java service?", + }, + ], + }); + + let fullText = ""; + for await (const chunk of textStream) { + fullText += chunk; + } + aiSDKResponses.push({ text: fullText }); + console.log("Cache create response with streamText"); + } catch (error) { + console.error("Cache create error:", error); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Second call - should read from cache with same large context + try { + const { textStream } = streamText({ + model: openai("gpt-5-nano"), + messages: [ + { + role: "system", + content: largeProgrammingContext, + }, + { + role: "user", + content: + "How would you redesign the database access pattern to reduce connection pool pressure?", + }, + ], + providerOptions: { + openai: { + stream_options: { + include_usage: true, + }, + }, + }, + }); + + let fullText = ""; + for await (const chunk of textStream) { + fullText += chunk; + } + aiSDKResponses.push({ text: fullText }); + console.log("Cache read response with streamText"); + } catch (error) { + console.error("Cache read error:", error); + } + + return "OpenAI cache streamText test completed"; + }, + { + name: "OpenAI Cache StreamText Test Wrapper", + metadata: { testKey: meta }, + client, + }, + ); + + await wrapper(); + + await client.awaitPendingTraceBatches(); +}); + +test.skip("ToolLoopAgent generate", async () => { + const agent = new ToolLoopAgent({ + model: openai("gpt-5-nano"), + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + }); + + const result = await agent.generate({ + prompt: "What are my orders? My user id is 1", + }); + console.log(result); +}); + +test.skip("ToolLoopAgent stream", async () => { + const agent = new ToolLoopAgent({ + model: openai("gpt-5-nano"), + tools: { + listOrders: tool({ + description: "list all orders", + inputSchema: z.object({ userId: z.string() }), + execute: async ({ userId }) => + `User ${userId} has the following orders: 1`, + }), + }, + }); + + const result = await agent.stream({ + prompt: "What are my orders? My user id is 1", + }); + for await (const chunk of result.textStream) { + console.log(chunk); + } +}); diff --git a/src/lib/tests/vercel/wrapper/sdk5-backcompat.vitesttest.ts b/src/lib/tests/vercel/wrapper/sdk5-backcompat.vitesttest.ts new file mode 100644 index 0000000..7983b6b --- /dev/null +++ b/src/lib/tests/vercel/wrapper/sdk5-backcompat.vitesttest.ts @@ -0,0 +1,1504 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import * as ai from "ai"; +import { simulateReadableStream, APICallError } from "ai"; +import { z } from "zod"; +import { describe, it, beforeEach, expect } from "vitest"; +import { MockLanguageModelV2 } from "../utils.js"; +import { + createLangSmithProviderOptions, + wrapAISDK, +} from "../../../experimental/vercel/index.js"; +import { traceable } from "../../../traceable.js"; + +// Track HTTP requests made by the real traceable function +const mockHttpRequests: any[] = []; + +// Mock LangSmith Client that captures HTTP calls +class MockLangSmithClient { + async createRun(runCreate: any) { + mockHttpRequests.push({ + method: "POST", + endpoint: "/runs", + body: runCreate, + type: "createRun", + timestamp: Date.now(), + }); + return { id: `mock-run-${Date.now()}` }; + } + + async updateRun(runId: string, runUpdate: any) { + mockHttpRequests.push({ + method: "PATCH", + endpoint: `/runs/${runId}`, + body: runUpdate, + type: "updateRun", + timestamp: Date.now(), + }); + return { id: runId }; + } +} + +describe("wrapAISDK", () => { + let mockClient: MockLangSmithClient; + + beforeEach(() => { + // Reset HTTP requests and create fresh mock client + process.env.LANGSMITH_TRACING = "true"; + mockHttpRequests.length = 0; + mockClient = new MockLangSmithClient(); + }); + + describe("tracing integration", () => { + it("should create traceable function calls", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + const mockLangModel = new MockLanguageModelV2({ + modelId: "trace-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Test response" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test prompt", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made to LangSmith in correct order + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + // The first createRun should be the high-level generateText call + const generateTextRun = mockHttpRequests[0]; + expect(generateTextRun.body.extra.metadata).toHaveProperty( + "ai_sdk_method", + "ai.generateText", + ); + + // The second createRun should be the low-level doGenerate call + const doGenerateRun = mockHttpRequests[1]; + expect(doGenerateRun.body.extra.metadata).toHaveProperty( + "ai_sdk_method", + "ai.doGenerate", + ); + }); + + it("should apply custom metadata in tracing config", async () => { + const wrappedWithMetadata = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { + name: "custom-tracer", + metadata: { + customField: "test-value", + version: "2.0", + }, + client: mockClient as any, + }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "metadata-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Metadata test" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedWithMetadata.generateText({ + model: mockLangModel, + prompt: "Test with metadata", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify custom metadata was applied in HTTP requests + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + // The first createRun should have the AI SDK metadata with custom fields + const generateTextRun = mockHttpRequests[0]; + const updateTextRun = mockHttpRequests[3]; + expect(generateTextRun.body.extra.metadata).toMatchObject({ + customField: "test-value", + version: "2.0", + ai_sdk_method: "ai.generateText", + }); + expect(updateTextRun.body.outputs).not.toHaveProperty( + "response_metadata", + ); + }); + + it("should allow configuring traceResponseMetadata", async () => { + const wrappedWithMetadata = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { + name: "custom-tracer", + traceResponseMetadata: true, + client: mockClient as any, + }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "metadata-test-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Metadata test" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedWithMetadata.generateText({ + model: mockLangModel, + prompt: "Test with traced steps", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence: createRun, createRun, updateRun, updateRun + expect(mockHttpRequests).toMatchObject([ + { type: "createRun" }, + { type: "createRun" }, + { type: "updateRun" }, + { type: "updateRun" }, + ]); + expect(mockHttpRequests[1].type).toBe("createRun"); + expect(mockHttpRequests[2].type).toBe("updateRun"); + expect(mockHttpRequests[3].type).toBe("updateRun"); + + const updateTextRun = mockHttpRequests[3]; + expect(updateTextRun.body.outputs.response_metadata).toHaveProperty( + "steps", + ); + }); + + it("should send error information to LangSmith on model failures", async () => { + // Create wrapped methods with mock client for this test + const wrappedMethodsWithMock = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "error-test-model", + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOGENERATE ERROR"); + }, + }); + + try { + await wrappedMethodsWithMock.generateText({ + model: mockLangModel, + prompt: "This should fail", + }); + expect(true).toBe(false); // Should not reach here + } catch (error: any) { + expect(error.message).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + } + + // Add a small delay to allow async operations to complete + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured and sent to LangSmith + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCall = mockHttpRequests.find( + (req) => req.type === "createRun", + ); + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toHaveProperty( + "prompt", + "This should fail", + ); + + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + }); + + it("should handle streamText with proper aggregation", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-test-model", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " " }, + { type: "text-delta", id: "text-1", delta: "world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Say hello", + }); + + // Consume the stream to trigger aggregation + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Hello world"); + + // Verify HTTP requests were made for streamText + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const generateTextPostRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPostRun).toBeDefined(); + const generateTextPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPatchRun).toBeDefined(); + expect(generateTextPatchRun.body.outputs.content).toMatchObject([ + { + type: "text", + text: "Hello world", + }, + ]); + }); + + it("should delay a parent traceable's end time until the child traceable ends", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-test-model", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Hello" }, + { type: "text-delta", id: "text-1", delta: " " }, + { type: "text-delta", id: "text-1", delta: "world" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 5, + outputTokens: 2, + totalTokens: 7, + }, + }, + ], + chunkDelayInMs: 10, + }), + }), + }); + + const start = new Date(); + const result = await traceable( + async () => { + return wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Say hello", + }); + }, + { + client: mockClient as any, + name: "parent-traceable", + }, + )(); + + // Should return quickly + expect(new Date().getTime() - start.getTime()).toBeLessThan(50); + + // Consume the stream to trigger aggregation + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(new Date().getTime() - start.getTime()).toBeGreaterThanOrEqual(50); + + expect(fullText).toBe("Hello world"); + + // Verify HTTP requests were made for streamText + expect(mockHttpRequests.length).toBe(6); // 3 createRun + 3 updateRun + + const parentCreateRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && req.body.name === "parent-traceable", + ); + expect(parentCreateRun).toBeDefined(); + const parentUpdateRun = mockHttpRequests.find( + (req) => req.type === "updateRun" && !req.body.parent_run_id, + ); + expect(parentUpdateRun).toBeDefined(); + const generateTextPostRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPostRun).toBeDefined(); + const generateTextPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(generateTextPatchRun).toBeDefined(); + expect(generateTextPatchRun.body.outputs.content).toMatchObject([ + { + type: "text", + text: "Hello world", + }, + ]); + const generateLLMPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doStream", + ); + expect(generateLLMPatchRun).toBeDefined(); + expect(parentUpdateRun.body.end_time).toBeGreaterThanOrEqual( + generateTextPatchRun.body.end_time, + ); + expect(parentUpdateRun.body.end_time).toBeGreaterThanOrEqual( + generateLLMPatchRun.body.end_time, + ); + }); + + it("should handle streamText errors properly", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + // Test error at doStream level + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-error-model", + doStream: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOSTREAM ERROR"); + }, + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "This should fail", + }); + + // Stream should be empty or error out when consumed due to doStream failure + for await (const textPart of result.textStream) { + console.log("Unexpected text part:", textPart); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith traces + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOSTREAM ERROR", + ); + }); + + it("should handle generateObject with proper output processing", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "object-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"name": "John", "age": 30}', + }, + ], + finishReason: "stop" as const, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: 10, + outputTokens: 8, + totalTokens: 18, + }, + warnings: [], + }), + }); + + const result = await wrappedMethods.generateObject({ + model: mockLangModel, + prompt: "Generate a person object", + schema: z.object({ + name: z.string(), + age: z.number(), + }), + }); + + expect(result.object).toEqual({ name: "John", age: 30 }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made for generateObject + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + const generateObjectRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateObject", + ); + expect(generateObjectRun).toBeDefined(); + }); + + it("should handle generateObject errors", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "object-error-model", + doGenerate: async () => { + throw new Error("TOTALLY EXPECTED MOCK DOGENERATE ERROR"); + }, + }); + + try { + await wrappedMethods.generateObject({ + model: mockLangModel, + prompt: "This should fail", + schema: z.object({ + name: z.string(), + }), + }); + expect(true).toBe(false); // Should not reach here + } catch (error: any) { + expect(error.message).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK DOGENERATE ERROR", + ); + }); + + it("should handle generateObject with explicit generics", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "object-generics-test-model", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"key": "test-value"}', + }, + ], + finishReason: "stop" as const, + usage: { + promptTokens: 10, + completionTokens: 8, + inputTokens: 10, + outputTokens: 8, + totalTokens: 18, + }, + warnings: [], + }), + }); + + const result = await wrappedMethods.generateObject< + z.ZodObject<{ key: z.ZodString; keybar: z.ZodString }>, + "object", + { key: string; keybar: string } + >({ + model: mockLangModel, + prompt: "Generate an object with a key", + // @ts-expect-error - bad schema for test + schema: z.object({ + key: z.string(), + }), + }); + + void result.object.key; + void result.object.keybar; + // @ts-expect-error - test nonpresent keys throw type error + void result.object.keybaz; + + expect(result.object).toEqual({ key: "test-value" }); + }); + + it("should create LangSmith traces for streamObject operations", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-object-trace-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: '{ "name": "John" }' }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + }, + ], + }), + }), + }); + + const { partialObjectStream } = wrappedMethods.streamObject({ + model: mockLangModel, + prompt: "Generate an object", + schema: z.object({ + name: z.string(), + }), + }); + + const chunks: unknown[] = []; + for await (const partialObject of partialObjectStream) { + chunks.push(partialObject); + } + expect(chunks.length).toEqual(1); + expect(chunks.at(-1)).toMatchObject({ + name: "John", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests.length).toEqual(4); + + const streamObjectRun = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamObject", + ); + expect(streamObjectRun).toBeDefined(); + const streamObjectPatchRun = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamObject", + ); + expect(streamObjectPatchRun).toBeDefined(); + expect(streamObjectPatchRun.body.outputs).toMatchObject({ + name: "John", + }); + }); + + it("should handle streamObject errors properly", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + // Test error at doStream level for streamObject + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-object-error-model", + doStream: async () => { + throw new Error("TOTALLY EXPECTED MOCK STREAMOBJECT DOSTREAM ERROR"); + }, + }); + + const result = wrappedMethods.streamObject({ + model: mockLangModel, + prompt: "This should fail", + schema: z.object({ + name: z.string(), + }), + }); + + // Stream should be empty or error out when consumed due to doStream failure + for await (const partialObject of result.partialObjectStream) { + console.log("Unexpected partial object:", partialObject); + } + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify error was captured in LangSmith traces + const updateRunCall = mockHttpRequests.find( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(updateRunCall).toBeDefined(); + expect(updateRunCall.body.error).toContain( + "TOTALLY EXPECTED MOCK STREAMOBJECT DOSTREAM ERROR", + ); + }); + + it("should handle rate limit retries in streaming", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + let callCount = 0; + const mockLangModel = new MockLanguageModelV2({ + modelId: "rate-limit-test-model", + doStream: async () => { + callCount++; + if (callCount <= 2) { + // First two calls hit rate limit + const delaySeconds = 0; + throw new APICallError({ + message: "Rate limit reached for requests", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 429, + isRetryable: true, + data: { + error: { + message: "Rate limit reached for requests", + type: "requests", + param: null, + code: "rate_limit_exceeded", + }, + }, + responseHeaders: { + "retry-after": delaySeconds.toString(), + "x-request-id": "req_abcdef123456", + }, + }); + } + + // Third call succeeds + return { + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: "Success after retry", + }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + }, + ], + }), + }; + }, + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Test rate limit retry", + }); + + // Consume the stream to verify it eventually succeeds + let fullText = ""; + for await (const textPart of result.textStream) { + fullText += textPart; + } + + expect(fullText).toBe("Success after retry"); + expect(callCount).toBe(3); // Should have retried twice + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP request patterns for streaming retries + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCalls = mockHttpRequests.filter( + (req) => req.type === "createRun", + ); + const updateRunCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun", + ); + + // With 3 doStream attempts (2 failures + 1 success), we should see: + // 4 createRun calls: 1 parent + 3 child doStream attempts + // 4 updateRun calls: 1 parent success + 2 child failures + 1 child success + expect(createRunCalls.length).toBe(4); + expect(updateRunCalls.length).toBe(4); + + // Verify the success/error pattern: 1 parent success + 2 child errors + 1 child success + const successUpdateCalls = mockHttpRequests.filter( + (req) => + req.type === "updateRun" && !req.body.error && req.body.outputs, + ); + const errorUpdateCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(successUpdateCalls.length).toBe(2); // 1 parent streamText + 1 child doStream success + expect(errorUpdateCalls.length).toBe(2); // 2 child doStream failures + }); + + it("should handle rate limit retries in generateText", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + let callCount = 0; + const mockLangModel = new MockLanguageModelV2({ + modelId: "generatetext-rate-limit-test-model", + doGenerate: async () => { + callCount++; + if (callCount <= 2) { + // First two calls hit rate limit + const delaySeconds = 0; + throw new APICallError({ + message: "Rate limit reached for requests", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 429, + isRetryable: true, + data: { + error: { + message: "Rate limit reached for requests", + type: "requests", + param: null, + code: "rate_limit_exceeded", + }, + }, + responseHeaders: { + "retry-after": delaySeconds.toString(), + "x-request-id": "req_generatetext123", + }, + }); + } + + // Third call succeeds + return { + content: [{ type: "text" as const, text: "Generated after retry" }], + finishReason: "stop" as const, + usage: { + promptTokens: 10, + completionTokens: 5, + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + }, + warnings: [], + }; + }, + }); + + const result = await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test generateText rate limit retry", + }); + + expect(result.text).toBe("Generated after retry"); + expect(callCount).toBe(3); // Should have retried twice + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP request patterns for retries + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCalls = mockHttpRequests.filter( + (req) => req.type === "createRun", + ); + const updateRunCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun", + ); + + // With 3 doGenerate attempts (2 failures + 1 success), we should see: + // 4 createRun calls: 1 parent + 3 child doGenerate attempts + // 4 updateRun calls: 2 child failures + 1 child success + 1 parent success + expect(createRunCalls.length).toBe(4); + expect(updateRunCalls.length).toBe(4); + + // Verify the success/error pattern: 1 parent success + 2 child errors + 1 child success + const successUpdateCalls = mockHttpRequests.filter( + (req) => + req.type === "updateRun" && !req.body.error && req.body.outputs, + ); + const errorUpdateCalls = mockHttpRequests.filter( + (req) => req.type === "updateRun" && req.body.error, + ); + + expect(successUpdateCalls.length).toBe(2); // 1 parent generateText + 1 child doGenerate success + expect(errorUpdateCalls.length).toBe(2); // 2 child doGenerate failures + + // Find the parent generateText success (has content array) + const parentSuccessCall = successUpdateCalls.find( + (req) => req.body.outputs.content?.[0]?.text, + ); + expect(parentSuccessCall).toBeDefined(); + expect(parentSuccessCall.body.outputs.content[0].text).toBe( + "Generated after retry", + ); + + // Verify the final successful call has usage metadata + expect( + parentSuccessCall.body.extra?.metadata?.usage_metadata, + ).toBeDefined(); + expect( + parentSuccessCall.body.extra.metadata.usage_metadata.total_tokens, + ).toBe(15); + }); + + it("should handle stream cancellation with abortController", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const abortController = new AbortController(); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "abort-test-model", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { type: "text-delta", id: "text-1", delta: "Lorem" }, + { type: "text-delta", id: "text-1", delta: " ipsum" }, + { type: "text-delta", id: "text-1", delta: " dolor" }, + { type: "text-delta", id: "text-1", delta: " sit" }, + { type: "text-delta", id: "text-1", delta: " amet" }, + { type: "text-delta", id: "text-1", delta: " consectetur" }, + { type: "text-delta", id: "text-1", delta: " adipiscing" }, + { type: "text-delta", id: "text-1", delta: " elit" }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 5, + outputTokens: 8, + totalTokens: 13, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "Tell me a lorem ipsum poem", + abortSignal: abortController.signal, + }); + + // Consume stream and abort after a few chunks + let chunkCount = 0; + let fullText = ""; + + for await (const textPart of result.textStream) { + fullText += textPart; + chunkCount++; + + if (chunkCount > 5) { + abortController.abort(); + break; + } + } + + expect(chunkCount).toBeGreaterThan(5); + expect(fullText).toBeTruthy(); + + // Add delay for async operations + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify HTTP requests were made even with cancellation + expect(mockHttpRequests.length).toBeGreaterThan(0); + + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.streamText", + ); + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toHaveProperty( + "prompt", + "Tell me a lorem ipsum poem", + ); + }); + }); + + describe("config resolution", () => { + it("should merge base and runtime configs correctly", async () => { + const baseConfig = { + name: "base-tracer", + metadata: { baseField: "base-value", shared: "base" }, + tags: ["base-tag"], + badClient: "bad", + }; + + const runtimeConfig = createLangSmithProviderOptions({ + name: "runtime-tracer", + metadata: { runtimeField: "runtime-value", shared: "runtime" }, + tags: ["runtime-tag"], + client: mockClient as any, + }); + + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + baseConfig, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "config-merge-test", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "Test config merging", + providerOptions: { + langsmith: runtimeConfig, + }, + }); + + // Verify merged config was used in trace + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.name).toBe("runtime-tracer"); // Runtime overrides base + expect(createRunCall.body.extra.metadata).toMatchObject({ + baseField: "base-value", + runtimeField: "runtime-value", + shared: "runtime", // Runtime wins for conflicts + ai_sdk_method: "ai.generateText", + }); + expect(createRunCall.body.tags).toEqual(["runtime-tag"]); // Runtime overrides base + }); + + it("should handle process inputs and output options", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "config-provider-test", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + const lsConfig = createLangSmithProviderOptions({ + processInputs: (inputs) => { + const { messages, prompt, ...rest } = inputs; + return { + ...rest, + messages: messages?.map((message) => ({ + ...message, + content: "REDACTED", + })), + prompt: "REDACTED", + }; + }, + processOutputs: (outputs) => { + // @ts-expect-error - outputs is wrapped one level deep + outputs.content; + const originalTracedMessage = { ...outputs.outputs }; + return { + ...originalTracedMessage, + content: "REDACTED", + }; + }, + processChildLLMRunInputs: (inputs) => { + const { prompt } = inputs; + return { + messages: prompt.map((message) => ({ + ...message, + content: "REDACTED CHILD INPUTS", + })), + }; + }, + processChildLLMRunOutputs: (outputs) => { + // @ts-expect-error - no wrapping for child outputs + outputs.outputs; + return { + ...outputs, + content: "REDACTED CHILD OUTPUTS", + role: "assistant", + }; + }, + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + messages: [ + { + role: "user", + content: "hello there!", + }, + ], + providerOptions: { + langsmith: lsConfig, + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + expect(mockHttpRequests).toHaveLength(4); + // Verify merged config was used in trace + const createRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + + expect(createRunCall).toBeDefined(); + expect(createRunCall.body.inputs).toMatchObject({ + prompt: "REDACTED", + messages: [ + { + content: "REDACTED", + }, + ], + }); + const createUpdateCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.generateText", + ); + expect(createUpdateCall.body.outputs).toMatchObject({ + content: "REDACTED", + }); + const createLLMRunCall = mockHttpRequests.find( + (req) => + req.type === "createRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(createLLMRunCall.body.inputs).toMatchObject({ + messages: [ + { + role: "user", + content: "REDACTED CHILD INPUTS", + }, + ], + }); + const updateLLMRunCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra.metadata.ai_sdk_method === "ai.doGenerate", + ); + expect(updateLLMRunCall.body.outputs).toMatchObject({ + content: "REDACTED CHILD OUTPUTS", + }); + }); + }); + + describe("model display name handling", () => { + it("should handle model with config provider", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + const modelWithConfig = new MockLanguageModelV2({ + modelId: "custom-model", + doGenerate: async () => ({ + content: [{ type: "text" as const, text: "Config test" }], + finishReason: "stop" as const, + usage: { + promptTokens: 5, + completionTokens: 3, + inputTokens: 5, + outputTokens: 3, + totalTokens: 8, + }, + warnings: [], + }), + }); + + // Add config property to simulate provider info + (modelWithConfig as any).config = { provider: "custom-provider" }; + + await wrappedMethods.generateText({ + model: modelWithConfig, + prompt: "Test config", + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Verify the trace was created with proper model name + expect(mockHttpRequests.length).toBe(4); // 2 createRun + 2 updateRun + + // Verify sequence and that first run uses the custom provider name + expect(mockHttpRequests[0].type).toBe("createRun"); + expect(mockHttpRequests[0].body.name).toBe("custom-provider"); + }); + }); + + describe("experimental_output handling", () => { + it.skip("should preserve experimental_output as structured object in outputs", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "experimental-output-test", + doGenerate: async () => ({ + content: [ + { + type: "text" as const, + text: '{"city":"Prague","temperature":15,"unit":"celsius","conditions":"sunny"}', + }, + ], + finishReason: "stop" as const, + usage: { + promptTokens: 10, + completionTokens: 20, + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + }, + warnings: [], + }), + }); + + await wrappedMethods.generateText({ + model: mockLangModel, + prompt: "What's the weather?", + // @ts-expect-error - experimental_output is not typed + experimental_output: ai.Output.object({ + schema: z.object({ + city: z.string(), + temperature: z.number(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }), + }), + }); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Find the updateRun call for generateText (parent run) + const updateGenerateTextCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.generateText", + ); + + expect(updateGenerateTextCall).toBeDefined(); + + // Verify outputs contain the structured object at top level (like generateObject) + expect(updateGenerateTextCall.body.outputs).toMatchObject({ + city: "Prague", + temperature: 15, + unit: "celsius", + conditions: "sunny", + }); + + // Verify it's NOT stringified + const outputsStr = JSON.stringify(updateGenerateTextCall.body.outputs); + expect(outputsStr).not.toContain("[object Object]"); + expect(outputsStr).not.toContain('\\"city\\"'); // Should not be double-escaped + }); + + it("should preserve experimental_output as structured object in streamText", async () => { + const wrappedMethods = wrapAISDK( + { + wrapLanguageModel: ai.wrapLanguageModel, + generateText: ai.generateText, + streamText: ai.streamText, + generateObject: ai.generateObject, + streamObject: ai.streamObject, + }, + { client: mockClient as any }, + ); + + const mockLangModel = new MockLanguageModelV2({ + modelId: "stream-experimental-output-test", + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: + '{"city":"Berlin","temperature":10,"unit":"celsius","conditions":"cloudy"}', + }, + { type: "text-end", id: "text-1" }, + { + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 10, + outputTokens: 20, + totalTokens: 30, + }, + }, + ], + }), + }), + }); + + const result = wrappedMethods.streamText({ + model: mockLangModel, + prompt: "What's the weather?", + // @ts-expect-error - experimental_output is not typed + experimental_output: ai.Output.object({ + schema: z.object({ + city: z.string(), + temperature: z.number(), + unit: z.enum(["celsius", "fahrenheit"]), + conditions: z.string(), + }), + }), + }); + + await result.consumeStream(); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + // Find the updateRun call for streamText (parent run) + const updateStreamTextCall = mockHttpRequests.find( + (req) => + req.type === "updateRun" && + req.body.extra?.metadata?.ai_sdk_method === "ai.streamText", + ); + + expect(updateStreamTextCall).toBeDefined(); + + // Verify outputs contain the structured object at top level (parsed from JSON text) + expect(updateStreamTextCall.body.outputs).toMatchObject({ + city: "Berlin", + temperature: 10, + unit: "celsius", + conditions: "cloudy", + }); + + // Verify it's NOT stringified + const outputsStr = JSON.stringify(updateStreamTextCall.body.outputs); + expect(outputsStr).not.toContain("[object Object]"); + expect(outputsStr).not.toContain('\\"city\\"'); // Should not be double-escaped + }); + }); +}); diff --git a/src/lib/tests/vercel_agent_type.test.ts b/src/lib/tests/vercel_agent_type.test.ts new file mode 100644 index 0000000..738bb27 --- /dev/null +++ b/src/lib/tests/vercel_agent_type.test.ts @@ -0,0 +1,100 @@ +import { RunTree } from "../run_trees.js"; +import { withRunTree } from "../traceable.js"; +import { + vercelLsAgentTypeMetadata, + resolveVercelLsAgentType, +} from "../experimental/vercel/_agent_type.js"; + +// createChild/postRun stubs let the mock parent pass isRunTree. +const buildParent = ( + metadata: Record | undefined, + runType?: string, +) => + ({ + run_type: runType, + extra: metadata !== undefined ? { metadata } : {}, + createChild: () => undefined, + postRun: () => Promise.resolve(), + }) as unknown as RunTree; + +test("returns root when no parent runtree (top-level Vercel call)", () => { + expect(resolveVercelLsAgentType()).toBe("root"); +}); + +test("returns undefined when nested with untagged parent and not tool", async () => { + const parent = buildParent({}, "chain"); + const resolved = await withRunTree(parent, () => + Promise.resolve(resolveVercelLsAgentType()), + ); + expect(resolved).toBeUndefined(); +}); + +test.each(["root", "middleware", "subagent", "compaction"] as const)( + "inherits user-supplied parent tag '%s' from ambient runtree", + async (parentTag) => { + const parent = buildParent({ ls_agent_type: parentTag }); + const resolved = await withRunTree(parent, () => + Promise.resolve(resolveVercelLsAgentType()), + ); + expect(resolved).toBe(parentTag); + }, +); + +test.each(["root", "middleware", "subagent", "compaction"] as const)( + "inherits user-supplied parent tag '%s' from explicitly-passed runtree", + (parentTag) => { + const parent = buildParent({ ls_agent_type: parentTag }); + expect(resolveVercelLsAgentType(parent)).toBe(parentTag); + }, +); + +test("returns subagent when parent.run_type='tool' (Vercel convention)", () => { + const parent = buildParent({}, "tool"); + expect(resolveVercelLsAgentType(parent)).toBe("subagent"); +}); + +test.each(["middleware", "subagent", "compaction"] as const)( + "user narrowing tag '%s' beats parent.run_type='tool'", + (narrowingTag) => { + const parent = buildParent({ ls_agent_type: narrowingTag }, "tool"); + expect(resolveVercelLsAgentType(parent)).toBe(narrowingTag); + }, +); + +test("parent.run_type='tool' overrides inherited root (structural detection)", () => { + const parent = buildParent({ ls_agent_type: "root" }, "tool"); + expect(resolveVercelLsAgentType(parent)).toBe("subagent"); +}); + +test("invalid parent tag falls through to run_type/undefined", () => { + const nonTool = buildParent({ ls_agent_type: "bogus" }, "chain"); + expect(resolveVercelLsAgentType(nonTool)).toBeUndefined(); + + const tool = buildParent({ ls_agent_type: "bogus" }, "tool"); + expect(resolveVercelLsAgentType(tool)).toBe("subagent"); +}); + +test("null on parent's ls_agent_type falls through to undefined", () => { + const parent = buildParent({ ls_agent_type: null }, "chain"); + expect(resolveVercelLsAgentType(parent)).toBeUndefined(); +}); + +test("non-RunTree parent returns root (safety default)", () => { + expect(resolveVercelLsAgentType(null)).toBe("root"); + expect(resolveVercelLsAgentType("not-a-runtree")).toBe("root"); + expect(resolveVercelLsAgentType(42)).toBe("root"); + // ContextPlaceholder (tracingEnabled=false) — treated as no parent. + expect(resolveVercelLsAgentType({ tracingEnabled: false })).toBe("root"); +}); + +test("spread helper yields { ls_agent_type } when a tag resolves", () => { + expect(vercelLsAgentTypeMetadata()).toEqual({ ls_agent_type: "root" }); +}); + +test("spread helper yields {} when resolver returns undefined", async () => { + const parent = buildParent({}, "chain"); + const spread = await withRunTree(parent, () => + Promise.resolve(vercelLsAgentTypeMetadata()), + ); + expect(spread).toEqual({}); +}); diff --git a/src/lib/tests/vitest_reporter.vitesttest.ts b/src/lib/tests/vitest_reporter.vitesttest.ts new file mode 100644 index 0000000..897894a --- /dev/null +++ b/src/lib/tests/vitest_reporter.vitesttest.ts @@ -0,0 +1,70 @@ +import { expect, test, vi } from "vitest"; +import LangSmithEvalReporter from "../vitest/reporter.js"; +import { printReporterTable } from "../utils/jestlike/reporter.js"; +import { printVitestTestModulesReporterTable } from "../vitest/utils/reporter.js"; + +vi.mock("vitest/reporters", () => ({ + DefaultReporter: class { + onTestRunEnd() {} + }, +})); + +vi.mock("../utils/jestlike/reporter.js", () => ({ + printReporterTable: vi.fn(), +})); + +test("reports iterable Vitest 4 results once when both lifecycle hooks run", async () => { + vi.mocked(printReporterTable).mockClear(); + const modules = [ + { + relativeModuleId: "eval.test.ts", + state: () => "passed" as const, + children: { + *allTests() { + for (const state of ["passed", "failed", "skipped"] as const) { + yield { + name: state, + result: () => ({ state }), + diagnostic: () => undefined, + }; + } + }, + }, + }, + ]; + const reporter = new LangSmithEvalReporter(); + await reporter.onTestRunEnd( + modules as unknown as Parameters[0], + [], + "passed", + ); + await reporter.onFinished([], []); + expect(printReporterTable).toHaveBeenCalledExactlyOnceWith( + "eval.test.ts", + [ + { title: "passed", status: "passed", duration: 0 }, + { title: "failed", status: "failed", duration: 0 }, + { title: "skipped", status: "skipped", duration: 0 }, + ], + "passed", + ); +}); + +test.each(["pending", "queued"] as const)( + "reports %s modules as skipped", + async (state) => { + vi.mocked(printReporterTable).mockClear(); + await printVitestTestModulesReporterTable([ + { + relativeModuleId: "eval.test.ts", + state: () => state, + children: { allTests: () => [] }, + }, + ]); + expect(printReporterTable).toHaveBeenCalledExactlyOnceWith( + "eval.test.ts", + [], + "skipped", + ); + }, +); diff --git a/src/lib/tests/wrapped_anthropic.int.test.ts b/src/lib/tests/wrapped_anthropic.int.test.ts new file mode 100644 index 0000000..757b06e --- /dev/null +++ b/src/lib/tests/wrapped_anthropic.int.test.ts @@ -0,0 +1,2100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import Anthropic, { toFile } from "@anthropic-ai/sdk"; +import { wrapAnthropic } from "../wrappers/anthropic.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { UsageMetadata } from "../schemas.js"; +import { generateLongContext } from "./utils.js"; +import { requiresProvider } from "./utils/markers.js"; +const { test, it, describe } = requiresProvider; + +function parseRequestBody(body: any) { + return body instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(body)) + : JSON.parse(body); +} + +describe.skip("Requires Anthropic API key", () => { + test("wrapAnthropic should return type compatible with Anthropic", async () => { + let originalClient = new Anthropic(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + originalClient = wrapAnthropic(originalClient); + + expect(true).toBe(true); + }); + + test("messages.create non-streaming", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // invoke + const original = await originalClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'foo'" }], + }); + + const patched = await patchedClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'foo'" }], + }); + + // Both should have content + expect(patched.content).toBeDefined(); + expect(patched.role).toBe("assistant"); + expect(original.role).toBe("assistant"); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + + // Verify metadata was set correctly + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify usage_metadata was captured + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThanOrEqual(1); + + const patchBody = parseRequestBody((patchCalls[0][1] as any).body); + expect(patchBody.outputs).toBeDefined(); + expect(patchBody.outputs.usage_metadata).toBeDefined(); + expect(patchBody.outputs.usage_metadata.input_tokens).toBeGreaterThan(0); + expect(patchBody.outputs.usage_metadata.output_tokens).toBeGreaterThan(0); + expect(patchBody.outputs.usage_metadata.total_tokens).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("messages.create streaming", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Stream with original client + const originalStream = await originalClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'foo'" }], + stream: true, + }); + + const originalEvents: any[] = []; + for await (const event of originalStream) { + originalEvents.push(event); + } + + // Stream with patched client + const patchedStream = await patchedClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'foo'" }], + stream: true, + }); + + const patchedEvents: any[] = []; + for await (const event of patchedStream) { + patchedEvents.push(event); + } + + // Both should have events + expect(patchedEvents.length).toBeGreaterThan(0); + expect(originalEvents.length).toBeGreaterThan(0); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + tokenEvents.forEach((event: any) => { + expect(event.name).toBe("new_token"); + expect(event.kwargs).toBeUndefined(); + expect(event.time).toBeDefined(); + }); + + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify aggregated output has usage_metadata + expect(body.outputs).toBeDefined(); + expect(body.outputs.usage_metadata).toBeDefined(); + expect(body.outputs.usage_metadata.input_tokens).toBeGreaterThan(0); + expect(body.outputs.usage_metadata.output_tokens).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("messages.create streaming break early", async () => { + const { client, callSpy } = mockClient(); + + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const patchedStreamToBreak = await patchedClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [{ role: "user", content: "Count from 1 to 100 slowly" }], + stream: true, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of patchedStreamToBreak) { + break; + } + + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + callSpy.mockClear(); + }); + + test("messages.create streaming with langsmithExtra", async () => { + const { client, callSpy } = mockClient(); + + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const patchedStreamWithMetadata = await patchedClient.messages.create( + { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'foo'" }], + stream: true, + }, + { + langsmithExtra: { + metadata: { + thing1: "thing2", + }, + }, + }, + ); + + const patchedChoices: unknown[] = []; + for await (const chunk of patchedStreamWithMetadata) { + patchedChoices.push(chunk); + } + + expect(patchedChoices.length).toBeGreaterThan(0); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + thing1: "thing2", + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + } + callSpy.mockClear(); + }); + + test("messages.stream", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Use messages.stream with original client + const originalStream = originalClient.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'bar'" }], + }); + + const originalEvents: any[] = []; + for await (const event of originalStream) { + originalEvents.push(event); + } + + // Use messages.stream with patched client + const patchedStream = patchedClient.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'bar'" }], + }); + + const patchedEvents: any[] = []; + for await (const event of patchedStream) { + patchedEvents.push(event); + } + + // Both should have events and final messages + expect(patchedEvents.length).toBeGreaterThan(0); + expect(originalEvents.length).toBeGreaterThan(0); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("messages.stream with finalMessage", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Use messages.stream with original client + const originalStream = originalClient.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'bar'" }], + }); + + const originalFinalMessage = await originalStream.finalMessage(); + + // Use messages.stream with patched client + const patchedStream = patchedClient.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'bar'" }], + }); + + const patchedFinalMessage = await patchedStream.finalMessage(); + + // Both should have events and final messages + expect(patchedFinalMessage.role).toBe("assistant"); + expect(originalFinalMessage.role).toBe("assistant"); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("messages.stream with langsmithExtra", async () => { + const { client, callSpy } = mockClient(); + + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const patchedStream = patchedClient.messages.stream( + { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'baz'" }], + }, + { + langsmithExtra: { + name: "custom_stream_name", + metadata: { + customKey: "customValue", + }, + }, + }, + ); + + const events: any[] = []; + for await (const event of patchedStream) { + events.push(event); + } + + expect(events.length).toBeGreaterThan(0); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + for (const call of callSpy.mock.calls) { + const body = parseRequestBody((call[1] as any).body); + expect(body.name).toBe("custom_stream_name"); + expect(body.extra.metadata).toMatchObject({ + customKey: "customValue", + ls_provider: "anthropic", + }); + } + + callSpy.mockClear(); + }); + + test("messages.create with tool calling", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const toolDefinition: Anthropic.Tool[] = [ + { + name: "get_weather", + description: "Get the current weather in a given location", + input_schema: { + type: "object", + properties: { + location: { + type: "string", + description: "The city, e.g. San Francisco", + }, + }, + required: ["location"], + }, + }, + ]; + + // Non-streaming tool call + const original = await originalClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [{ role: "user", content: "What's the weather in SF?" }], + tools: toolDefinition, + tool_choice: { type: "tool", name: "get_weather" }, + }); + + const patched = await patchedClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [{ role: "user", content: "What's the weather in SF?" }], + tools: toolDefinition, + tool_choice: { type: "tool", name: "get_weather" }, + }); + + // Both should have tool_use content blocks + expect(patched.content).toBeDefined(); + expect(patched.content.some((block) => block.type === "tool_use")).toBe( + true, + ); + expect(original.content.some((block) => block.type === "tool_use")).toBe( + true, + ); + + // Verify tracing was done + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + const patchBody = parseRequestBody((patchCalls[0][1] as any).body); + expect(patchBody.outputs).toBeDefined(); + expect(patchBody.outputs.content).toBeDefined(); + + callSpy.mockClear(); + }); + + test("messages.create streaming with tool calling", async () => { + const { client, callSpy } = mockClient(); + + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const toolDefinition: Anthropic.Tool[] = [ + { + name: "get_weather", + description: "Get the current weather in a given location", + input_schema: { + type: "object", + properties: { + location: { + type: "string", + description: "The city, e.g. San Francisco", + }, + }, + required: ["location"], + }, + }, + ]; + + const patchedStream = await patchedClient.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [{ role: "user", content: "What's the weather in SF?" }], + tools: toolDefinition, + tool_choice: { type: "tool", name: "get_weather" }, + stream: true, + }); + + const events: any[] = []; + for await (const event of patchedStream) { + events.push(event); + } + + expect(events.length).toBeGreaterThan(0); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify the aggregated output contains tool_use + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.outputs).toBeDefined(); + expect(body.outputs.content).toBeDefined(); + expect( + body.outputs.content.some((block: any) => block.type === "tool_use"), + ).toBe(true); + + callSpy.mockClear(); + }); + + test("wrapping same instance", async () => { + const wrapped = wrapAnthropic(new Anthropic()); + expect(() => wrapAnthropic(wrapped)).toThrowError( + "This instance of Anthropic client has been already wrapped once.", + ); + }); + + test("beta.messages.create", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Verify beta namespace exists + expect(patchedClient.beta).toBeDefined(); + expect(patchedClient.beta.messages).toBeDefined(); + + // Non-streaming beta call + const original = await originalClient.beta.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta'" }], + betas: ["computer-use-2025-01-24"], + }); + + const patched = await patchedClient.beta.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta'" }], + betas: ["computer-use-2025-01-24"], + }); + + expect(patched.content).toBeDefined(); + expect(patched.role).toBe("assistant"); + expect(original.role).toBe("assistant"); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata was set correctly + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + callSpy.mockClear(); + }); + + test("beta.messages.parse", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + expect(patchedClient.beta.messages.parse).toBeDefined(); + + const original = await originalClient.beta.messages.parse({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [ + { + role: "user", + content: "Alice and Bob are going to a science fair on Friday.", + }, + ], + }); + + const patched = await patchedClient.beta.messages.parse({ + model: "claude-haiku-4-5", + max_tokens: 1024, + messages: [ + { + role: "user", + content: "Alice and Bob are going to a science fair on Friday.", + }, + ], + }); + + expect(patched.content).toBeDefined(); + expect(patched.role).toBe("assistant"); + expect(original.role).toBe("assistant"); + + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + callSpy.mockClear(); + }); + + test("beta.messages.create streaming", async () => { + const { client, callSpy } = mockClient(); + + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + const patchedStream = await patchedClient.beta.messages.create({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta stream'" }], + betas: ["computer-use-2025-01-24"], + stream: true, + }); + + const events: any[] = []; + for await (const event of patchedStream) { + events.push(event); + } + + expect(events.length).toBeGreaterThan(0); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + callSpy.mockClear(); + }); + + test("beta.messages.stream", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Use beta.messages.stream with original client + const originalStream = originalClient.beta.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta stream method'" }], + betas: ["computer-use-2025-01-24"], + }); + + const originalEvents: any[] = []; + for await (const event of originalStream) { + originalEvents.push(event); + } + + // Use beta.messages.stream with patched client + const patchedStream = patchedClient.beta.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta stream method'" }], + betas: ["computer-use-2025-01-24"], + }); + + const patchedEvents: any[] = []; + for await (const event of patchedStream) { + patchedEvents.push(event); + } + + // Both should have events + expect(patchedEvents.length).toBeGreaterThan(0); + expect(originalEvents.length).toBeGreaterThan(0); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("beta.messages.stream with finalMessage", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new Anthropic(); + const patchedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + // Use beta.messages.stream with original client + const originalStream = originalClient.beta.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta final'" }], + betas: ["computer-use-2025-01-24"], + }); + + const originalFinalMessage = await originalStream.finalMessage(); + + // Use beta.messages.stream with patched client + const patchedStream = patchedClient.beta.messages.stream({ + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'beta final'" }], + betas: ["computer-use-2025-01-24"], + }); + + const patchedFinalMessage = await patchedStream.finalMessage(); + + // Both should have final messages + expect(patchedFinalMessage.role).toBe("assistant"); + expect(originalFinalMessage.role).toBe("assistant"); + + // Verify tracing calls were made + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify metadata + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThanOrEqual(1); + + const postBody = parseRequestBody((postCalls[0][1] as any).body); + expect(postBody.extra.metadata).toMatchObject({ + ls_model_name: "claude-haiku-4-5", + ls_model_type: "chat", + ls_provider: "anthropic", + }); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + + callSpy.mockClear(); + }); + + test("chat extra name", async () => { + const { client, callSpy } = mockClient(); + + const anthropic = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + }); + + await anthropic.messages.create( + { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'red'" }], + }, + { langsmithExtra: { name: "red", metadata: { customKey: "red" } } }, + ); + + const stream = await anthropic.messages.create( + { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "Say 'green'" }], + stream: true, + }, + { langsmithExtra: { name: "green", metadata: { customKey: "green" } } }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of stream) { + // pass + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["red:0", "green:1"], + edges: [], + data: { + "red:0": { + name: "red", + extra: { metadata: { customKey: "red" } }, + outputs: { + content: expect.any(Array), + role: "assistant", + }, + }, + "green:1": { + name: "green", + extra: { metadata: { customKey: "green" } }, + outputs: { + content: expect.any(Array), + role: "assistant", + }, + }, + }, + }); + }); + + const usageMetadataTestCases = [ + { + description: "stream with usage", + params: { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "howdy" }], + stream: true, + }, + expectUsageMetadata: true, + }, + { + description: "default", + params: { + model: "claude-haiku-4-5", + max_tokens: 100, + messages: [{ role: "user", content: "howdy" }], + }, + expectUsageMetadata: true, + }, + ]; + + // Token intensive test, so skipping by default + describe.skip("Anthropic Prompt Caching Tests", () => { + test("5-minute ephemeral cache", async () => { + const patchedClient = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + }); + + const longContext = generateLongContext(); + + // First call - creates 5-minute cache + const response1 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 1024, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "5m" }, + }, + ], + messages: [ + { role: "user", content: "Summarize the main error briefly." }, + ], + }); + + expect( + response1.usage.cache_creation?.ephemeral_5m_input_tokens, + ).toBeGreaterThan(0); + + // Second call - should read from 5-minute cache + const response2 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 1024, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "5m" }, + }, + ], + messages: [ + { role: "user", content: "What are the recommended fixes?" }, + ], + }); + + expect(response2.usage).toBeDefined(); + expect(response2.usage.cache_read_input_tokens).toBeGreaterThan(0); + }); + + test("1-hour extended cache", async () => { + const patchedClient = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + }); + + const longContext = generateLongContext(); + + // First call - creates 1-hour extended cache + const response1 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 1024, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "1h" }, + }, + ], + messages: [ + { role: "user", content: "What is the primary error message?" }, + ], + }); + + expect( + response1.usage.cache_creation?.ephemeral_1h_input_tokens, + ).toBeGreaterThan(0); + + // Second call - should read from 1-hour extended cache + const response2 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 1024, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "1h" }, + }, + ], + messages: [ + { + role: "user", + content: "What services are mentioned in the stack?", + }, + ], + }); + + expect(response2.usage).toBeDefined(); + expect(response2.usage.cache_read_input_tokens).toBeGreaterThan(0); + }); + + test("5-minute ephemeral cache with streaming and finalMessage", async () => { + const patchedClient = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + }); + + const longContext = generateLongContext(); + + // First call - creates 5-minute cache + const stream1 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 100, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "5m" }, + }, + ], + messages: [ + { role: "user", content: "Summarize the main error briefly." }, + ], + stream: true, + }); + + const events1: any[] = []; + for await (const event of stream1) { + events1.push(event); + } + + expect(events1.length).toBeGreaterThan(0); + + // Second call - should read from 5-minute cache using finalMessage + const stream2 = patchedClient.beta.messages.stream({ + model: "claude-sonnet-4-5", + max_tokens: 100, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "5m" }, + }, + ], + messages: [ + { role: "user", content: "What are the recommended fixes?" }, + ], + }); + + const finalMessage = await stream2.finalMessage(); + + expect(finalMessage.role).toBe("assistant"); + expect(finalMessage.usage).toBeDefined(); + expect(finalMessage.usage.cache_read_input_tokens).toBeGreaterThan(0); + }); + + test("1-hour extended cache with streaming and finalMessage", async () => { + const patchedClient = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + }); + + const longContext = generateLongContext(); + + // First call - creates 1-hour cache + const stream1 = await patchedClient.beta.messages.create({ + model: "claude-sonnet-4-5", + max_tokens: 100, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "1h" }, + }, + ], + messages: [ + { role: "user", content: "What is the primary error message?" }, + ], + stream: true, + }); + + const events1: any[] = []; + for await (const event of stream1) { + events1.push(event); + } + + expect(events1.length).toBeGreaterThan(0); + + // Second call - should read from 1-hour cache using finalMessage + const stream2 = patchedClient.beta.messages.stream({ + model: "claude-sonnet-4-5", + max_tokens: 100, + betas: ["prompt-caching-2024-07-31"], + system: [ + { + type: "text", + text: "You are a helpful assistant that analyzes error logs.", + }, + { + type: "text", + text: longContext, + cache_control: { type: "ephemeral", ttl: "1h" }, + }, + ], + messages: [ + { + role: "user", + content: "What services are mentioned in the stack?", + }, + ], + }); + + const finalMessage = await stream2.finalMessage(); + + expect(finalMessage.role).toBe("assistant"); + expect(finalMessage.usage).toBeDefined(); + expect(finalMessage.usage.cache_read_input_tokens).toBeGreaterThan(0); + }); + }); + + describe("Usage Metadata Tests", () => { + usageMetadataTestCases.forEach( + ({ description, params, expectUsageMetadata }) => { + it(`should handle ${description}`, async () => { + const { client, callSpy } = mockClient(); + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + client, + }); + + const requestParams = { ...params } as Anthropic.MessageCreateParams; + + let anthropicUsage: Anthropic.Usage | undefined; + if ((requestParams as any).stream) { + const stream = await anthropic.messages.create( + requestParams as Anthropic.MessageCreateParamsStreaming, + ); + for await (const event of stream) { + if (event.type === "message_start" && event.message?.usage) { + anthropicUsage = event.message.usage; + } + if (event.type === "message_delta" && event.usage) { + anthropicUsage = { + ...anthropicUsage, + output_tokens: event.usage.output_tokens, + } as Anthropic.Usage; + } + } + } else { + const res = await anthropic.messages.create( + requestParams as Anthropic.MessageCreateParamsNonStreaming, + ); + anthropicUsage = res.usage; + } + + let usageMetadata: UsageMetadata | undefined; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const requestBody = parseRequestBody(request.body); + if (requestBody.outputs && requestBody.outputs.usage_metadata) { + usageMetadata = requestBody.outputs.usage_metadata; + break; + } + } + + if (expectUsageMetadata) { + expect(usageMetadata).not.toBeUndefined(); + expect(usageMetadata).not.toBeNull(); + expect(anthropicUsage).not.toBeUndefined(); + expect(anthropicUsage).not.toBeNull(); + expect(usageMetadata!.input_tokens).toEqual( + anthropicUsage!.input_tokens, + ); + expect(usageMetadata!.output_tokens).toEqual( + anthropicUsage!.output_tokens, + ); + expect(usageMetadata!.total_tokens).toEqual( + anthropicUsage!.input_tokens + anthropicUsage!.output_tokens, + ); + } else { + expect(usageMetadata).toBeUndefined(); + expect(anthropicUsage).toBeUndefined(); + } + + callSpy.mockClear(); + }); + }, + ); + }); +}); + +describe.skip("Claude Managed Agents - requires Anthropic API key", () => { + test("creates agent, environment, session, and streams traced events", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "anthropic", "integration-test"], + metadata: { + test: "langsmith-managed-agents-wrapper", + test_run_id: suffix, + }, + }); + + console.log(`LangSmith managed agents test_run_id: ${suffix}`); + let agentID: string | undefined; + let environmentID: string | undefined; + let sessionID: string | undefined; + + try { + const agent = await anthropic.beta.agents.create({ + name: `LangSmith Managed Agents Test ${suffix}`, + model: process.env.ANTHROPIC_MANAGED_AGENTS_MODEL ?? "claude-opus-4-8", + system: + "You are a concise test agent. Use the bash tool when the user asks you to run a command, then report the result exactly.", + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { + name: "bash", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + { name: "edit", enabled: false }, + { name: "glob", enabled: false }, + { name: "grep", enabled: false }, + { name: "read", enabled: false }, + { name: "web_fetch", enabled: false }, + { name: "web_search", enabled: false }, + { name: "write", enabled: false }, + ], + }, + ], + metadata: { test: "langsmith-managed-agents-wrapper" }, + }); + agentID = agent.id; + expect(agent.version).toBeGreaterThanOrEqual(1); + + const environment = await anthropic.beta.environments.create({ + name: `langsmith-managed-agents-test-${suffix}`, + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { test: "langsmith-managed-agents-wrapper" }, + }); + environmentID = environment.id; + + const session = await anthropic.beta.sessions.create({ + agent: agent.id, + environment_id: environment.id, + title: "LangSmith managed agents wrapper integration test", + }); + sessionID = session.id; + + const stream = await anthropic.beta.sessions.events.stream(session.id); + await anthropic.beta.sessions.events.send(session.id, { + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: "Use the bash tool to run: printf 'LangSmith managed agent tool tracing works'. Then reply with exactly the command output and no extra text.", + }, + ], + }, + ], + }); + + const streamedEvents: any[] = []; + const toolUseEvents: any[] = []; + const toolResultEvents: any[] = []; + let assistantText = ""; + for await (const event of stream) { + streamedEvents.push(event); + if (event.type === "agent.tool_use") { + toolUseEvents.push(event); + } else if (event.type === "agent.tool_result") { + toolResultEvents.push(event); + } + if (event.type === "agent.message") { + assistantText += event.content.map((block) => block.text).join(""); + } else if (event.type === "session.error") { + throw new Error( + `Managed agent session error: ${event.error?.message ?? "unknown"}`, + ); + } else if (event.type === "session.status_idle") { + break; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(streamedEvents.length).toBeGreaterThan(0); + expect( + streamedEvents.some((event) => event.type === "agent.message"), + ).toBe(true); + expect(toolUseEvents.length).toBeGreaterThan(0); + expect(toolUseEvents.some((event) => event.name === "bash")).toBe(true); + expect(toolResultEvents.length).toBeGreaterThan(0); + expect(assistantText).toContain( + "LangSmith managed agent tool tracing works", + ); + + const retrievedSession = await anthropic.beta.sessions.retrieve( + session.id, + ); + expect(retrievedSession.id).toBe(session.id); + expect(retrievedSession.usage).toBeDefined(); + } finally { + if (sessionID) { + await anthropic.beta.sessions.delete(sessionID).catch(() => undefined); + } + if (environmentID) { + await anthropic.beta.environments + .delete(environmentID) + .catch(() => undefined); + } + if (agentID) { + await anthropic.beta.agents.archive(agentID).catch(() => undefined); + } + } + }); + + test("streams web search tool events", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "anthropic", "integration-test", "web-search"], + metadata: { + test: "langsmith-managed-agents-web-search-wrapper", + test_run_id: suffix, + }, + }); + + console.log(`LangSmith managed agents web search test_run_id: ${suffix}`); + let agentID: string | undefined; + let environmentID: string | undefined; + let sessionID: string | undefined; + + try { + const agent = await anthropic.beta.agents.create({ + name: `LangSmith Managed Agents Web Search Test ${suffix}`, + model: process.env.ANTHROPIC_MANAGED_AGENTS_MODEL ?? "claude-opus-4-8", + system: + "You are a concise test agent. Use the web_search tool when the user asks you to search, then answer using the result.", + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { name: "bash", enabled: false }, + { name: "edit", enabled: false }, + { name: "glob", enabled: false }, + { name: "grep", enabled: false }, + { name: "read", enabled: false }, + { name: "web_fetch", enabled: false }, + { + name: "web_search", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + { name: "write", enabled: false }, + ], + }, + ], + metadata: { test: "langsmith-managed-agents-web-search-wrapper" }, + }); + agentID = agent.id; + + const environment = await anthropic.beta.environments.create({ + name: `langsmith-managed-agents-web-search-test-${suffix}`, + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { test: "langsmith-managed-agents-web-search-wrapper" }, + }); + environmentID = environment.id; + + const session = await anthropic.beta.sessions.create({ + agent: agent.id, + environment_id: environment.id, + title: "LangSmith managed agents web search wrapper integration test", + }); + sessionID = session.id; + + const stream = await anthropic.beta.sessions.events.stream(session.id); + await anthropic.beta.sessions.events.send(session.id, { + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: + process.env.ANTHROPIC_MANAGED_AGENTS_WEB_SEARCH_PROMPT ?? + "Use web_search to search for the official LangSmith product page, then answer with the page title or product name only.", + }, + ], + }, + ], + }); + + const streamedEvents: any[] = []; + const webSearchToolUseEvents: any[] = []; + const webSearchToolResultEvents: any[] = []; + let assistantText = ""; + for await (const event of stream) { + streamedEvents.push(event); + if (event.type === "agent.tool_use" && event.name === "web_search") { + webSearchToolUseEvents.push(event); + } else if ( + event.type === "agent.tool_result" && + webSearchToolUseEvents.some( + (toolUse) => toolUse.id === event.tool_use_id, + ) + ) { + webSearchToolResultEvents.push(event); + } else if (event.type === "agent.message") { + assistantText += event.content.map((block) => block.text).join(""); + } else if (event.type === "session.error") { + throw new Error( + `Managed agent web search session error: ${ + event.error?.message ?? "unknown" + }`, + ); + } else if (event.type === "session.status_idle") { + break; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(streamedEvents.length).toBeGreaterThan(0); + expect(webSearchToolUseEvents.length).toBeGreaterThan(0); + expect(webSearchToolResultEvents.length).toBeGreaterThan(0); + expect(assistantText.length).toBeGreaterThan(0); + + const retrievedSession = await anthropic.beta.sessions.retrieve( + session.id, + ); + expect(retrievedSession.id).toBe(session.id); + expect(retrievedSession.usage).toBeDefined(); + } finally { + if (sessionID) { + await anthropic.beta.sessions.delete(sessionID).catch(() => undefined); + } + if (environmentID) { + await anthropic.beta.environments + .delete(environmentID) + .catch(() => undefined); + } + if (agentID) { + await anthropic.beta.agents.archive(agentID).catch(() => undefined); + } + } + }); + + test("streams MCP server tool events", async () => { + const mcpServerUrl = process.env.ANTHROPIC_MANAGED_AGENTS_MCP_SERVER_URL; + const mcpPrompt = process.env.ANTHROPIC_MANAGED_AGENTS_MCP_PROMPT; + const expectedToolName = process.env.ANTHROPIC_MANAGED_AGENTS_MCP_TOOL_NAME; + if (!mcpServerUrl || !mcpPrompt) { + console.warn( + "Skipping MCP managed agents integration test. Set ANTHROPIC_MANAGED_AGENTS_MCP_SERVER_URL and ANTHROPIC_MANAGED_AGENTS_MCP_PROMPT to run it.", + ); + return; + } + + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const mcpServerName = `test_mcp_${suffix.replace(/[^a-zA-Z0-9_-]/g, "_")}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "anthropic", "integration-test", "mcp"], + metadata: { + test: "langsmith-managed-agents-mcp-wrapper", + test_run_id: suffix, + }, + }); + + console.log(`LangSmith managed agents MCP test_run_id: ${suffix}`); + let agentID: string | undefined; + let environmentID: string | undefined; + let sessionID: string | undefined; + + try { + const mcpToolset: any = { + type: "mcp_toolset", + mcp_server_name: mcpServerName, + default_config: { + enabled: true, + permission_policy: { type: "always_allow" }, + }, + }; + if (expectedToolName) { + mcpToolset.configs = [ + { + name: expectedToolName, + enabled: true, + permission_policy: { type: "always_allow" }, + }, + ]; + } + + const agent = await anthropic.beta.agents.create({ + name: `LangSmith Managed Agents MCP Test ${suffix}`, + model: process.env.ANTHROPIC_MANAGED_AGENTS_MODEL ?? "claude-opus-4-8", + system: + "You are a concise test agent. Use the available MCP tool when the user asks, then report the result.", + mcp_servers: [ + { + name: mcpServerName, + type: "url", + url: mcpServerUrl, + }, + ], + tools: [mcpToolset], + metadata: { test: "langsmith-managed-agents-mcp-wrapper" }, + }); + agentID = agent.id; + + const environment = await anthropic.beta.environments.create({ + name: `langsmith-managed-agents-mcp-test-${suffix}`, + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { test: "langsmith-managed-agents-mcp-wrapper" }, + }); + environmentID = environment.id; + + const session = await anthropic.beta.sessions.create({ + agent: agent.id, + environment_id: environment.id, + title: "LangSmith managed agents MCP wrapper integration test", + }); + sessionID = session.id; + + const stream = await anthropic.beta.sessions.events.stream(session.id); + await anthropic.beta.sessions.events.send(session.id, { + events: [ + { + type: "user.message", + content: [{ type: "text", text: mcpPrompt }], + }, + ], + }); + + const streamedEvents: any[] = []; + const mcpToolUseEvents: any[] = []; + const mcpToolResultEvents: any[] = []; + let assistantText = ""; + for await (const event of stream) { + streamedEvents.push(event); + if (event.type === "agent.mcp_tool_use") { + mcpToolUseEvents.push(event); + } else if (event.type === "agent.mcp_tool_result") { + mcpToolResultEvents.push(event); + } else if (event.type === "agent.message") { + assistantText += event.content.map((block) => block.text).join(""); + } else if (event.type === "session.error") { + throw new Error( + `Managed agent MCP session error: ${ + event.error?.message ?? "unknown" + }`, + ); + } else if (event.type === "session.status_idle") { + break; + } + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + expect(streamedEvents.length).toBeGreaterThan(0); + expect(mcpToolUseEvents.length).toBeGreaterThan(0); + expect(mcpToolResultEvents.length).toBeGreaterThan(0); + expect( + mcpToolUseEvents.every( + (event) => event.mcp_server_name === mcpServerName, + ), + ).toBe(true); + if (expectedToolName) { + expect( + mcpToolUseEvents.some((event) => event.name === expectedToolName), + ).toBe(true); + } + expect(assistantText.length).toBeGreaterThan(0); + + const retrievedSession = await anthropic.beta.sessions.retrieve( + session.id, + ); + expect(retrievedSession.id).toBe(session.id); + expect(retrievedSession.usage).toBeDefined(); + } finally { + if (sessionID) { + await anthropic.beta.sessions.delete(sessionID).catch(() => undefined); + } + if (environmentID) { + await anthropic.beta.environments + .delete(environmentID) + .catch(() => undefined); + } + if (agentID) { + await anthropic.beta.agents.archive(agentID).catch(() => undefined); + } + } + }); + + describe("additional managed agent scenarios", () => { + async function createManagedAgentFixture( + anthropic: any, + suffix: string, + agentParams: any, + ) { + const agent = await anthropic.beta.agents.create({ + name: `LangSmith Managed Agents Scenario Test ${suffix}`, + model: process.env.ANTHROPIC_MANAGED_AGENTS_MODEL ?? "claude-opus-4-8", + metadata: { test: "langsmith-managed-agents-scenario-wrapper" }, + ...agentParams, + }); + const environment = await anthropic.beta.environments.create({ + name: `langsmith-managed-agents-scenario-test-${suffix}`, + config: { + type: "cloud", + networking: { type: "unrestricted" }, + }, + metadata: { test: "langsmith-managed-agents-scenario-wrapper" }, + }); + const session = await anthropic.beta.sessions.create({ + agent: agent.id, + environment_id: environment.id, + title: "LangSmith managed agents skipped scenario integration test", + }); + return { + agent, + environment, + session, + cleanup: async () => { + await anthropic.beta.sessions + .delete(session.id) + .catch(() => undefined); + await anthropic.beta.environments + .delete(environment.id) + .catch(() => undefined); + await anthropic.beta.agents.archive(agent.id).catch(() => undefined); + }, + }; + } + + test("handles user.interrupt", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "interrupt", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: "You are a test agent. Use bash for long-running commands.", + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { + name: "bash", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + ], + }, + ], + }); + try { + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: "Run a long bash loop, then report completion.", + }, + ], + }, + ], + }); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [{ type: "user.interrupt" }], + }); + const events: any[] = []; + for await (const event of stream) { + events.push(event); + if (event.type === "session.status_idle") break; + } + expect(events.some((event) => event.type === "user.interrupt")).toBe( + true, + ); + } finally { + await fixture.cleanup(); + } + }); + + test("handles tool confirmation with user.tool_confirmation", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "tool-confirmation", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: "You are a test agent. Use bash when asked.", + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { + name: "bash", + enabled: true, + permission_policy: { type: "always_ask" }, + }, + ], + }, + ], + }); + try { + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [{ type: "text", text: "Use bash to echo hello." }], + }, + ], + }); + const eventsById = new Map(); + for await (const event of stream) { + if (event.id) eventsById.set(event.id, event); + if (event.type === "session.status_idle") { + if (event.stop_reason?.type === "requires_action") { + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: event.stop_reason.event_ids.map((eventId: string) => ({ + type: "user.tool_confirmation", + tool_use_id: eventId, + result: "allow", + })), + }); + continue; + } + break; + } + } + expect( + [...eventsById.values()].some( + (event) => event.type === "agent.tool_use", + ), + ).toBe(true); + } finally { + await fixture.cleanup(); + } + }); + + test("handles custom tool result via user.custom_tool_result", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "custom-tool-result", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: "You are a test agent. Use the custom tool when asked.", + tools: [ + { + type: "custom", + name: "lookup_test_value", + description: "Returns a deterministic test value.", + input_schema: { + type: "object", + properties: { key: { type: "string" } }, + required: ["key"], + }, + }, + ], + }); + try { + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [ + { type: "text", text: "Use lookup_test_value for key foo." }, + ], + }, + ], + }); + const customToolUses = new Map(); + for await (const event of stream) { + if (event.type === "agent.custom_tool_use") { + customToolUses.set(event.id, event); + } + if (event.type === "session.status_idle") { + if (event.stop_reason?.type === "requires_action") { + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: event.stop_reason.event_ids.map((eventId: string) => ({ + type: "user.custom_tool_result", + custom_tool_use_id: eventId, + content: [{ type: "text", text: "bar" }], + })), + }); + continue; + } + break; + } + } + expect(customToolUses.size).toBeGreaterThan(0); + } finally { + await fixture.cleanup(); + } + }); + + test("handles user.define_outcome when supported by the SDK/API", async () => { + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + }); + const sessionID = process.env.ANTHROPIC_MANAGED_AGENTS_SESSION_ID; + expect(sessionID).toBeDefined(); + await anthropic.beta.sessions.events.send(sessionID!, { + events: [ + { + type: "user.define_outcome", + outcome: { type: "success", description: "Test outcome" }, + } as any, + ], + } as any); + }); + + // TODO: figure out how to support this properly + test.skip("streams subagent/thread status events", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "subagents", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: + "You are a coordinator agent. Delegate work to a subagent/session thread when asked.", + multiagent: { + type: "coordinator", + agents: [{ type: "self" }], + }, + }); + try { + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [ + { + type: "text", + text: "Delegate a small task, like writing a haiku.", + }, + ], + }, + ], + }); + const events: any[] = []; + for await (const event of stream) { + events.push(event); + if (event.type === "session.status_idle") break; + } + expect( + events.some((event) => + String(event.type).startsWith("session.thread_status_"), + ), + ).toBe(true); + } finally { + await fixture.cleanup(); + } + }); + + test.skip("handles file resources and skills", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "files", "skills", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const file = await toFile( + new TextEncoder().encode( + "city,weather,temperature\nPrague,sunny,23C\nParis,cloudy,18C\n", + ), + `langsmith-managed-agents-test-${suffix}.csv`, + { type: "text/csv" }, + ); + const uploadedFile = await anthropic.beta.files.upload({ file }); + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: "Use the attached file and skill to answer.", + skills: [{ type: "anthropic", skill_id: "xlsx" }], + tools: [ + { + type: "agent_toolset_20260401", + configs: [ + { + name: "read", + enabled: true, + permission_policy: { type: "always_allow" }, + }, + ], + }, + ], + }); + let resourceID: string | undefined; + try { + const resource = await anthropic.beta.sessions.resources.add( + fixture.session.id, + { + type: "file", + file_id: uploadedFile.id, + }, + ); + resourceID = resource.id; + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [{ type: "text", text: "Summarize the attached file." }], + }, + ], + }); + const events: any[] = []; + for await (const event of stream) { + events.push(event); + if (event.type === "session.status_idle") break; + } + expect(events.some((event) => event.type === "agent.message")).toBe( + true, + ); + } finally { + if (resourceID) { + await anthropic.beta.sessions.resources + .delete(resourceID, { session_id: fixture.session.id }) + .catch(() => undefined); + } + await fixture.cleanup(); + await anthropic.beta.files + .delete(uploadedFile.id) + .catch(() => undefined); + } + }); + + test("handles image attachments in user messages", async () => { + const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; + const anthropic = wrapAnthropic(new Anthropic(), { + tracingEnabled: true, + tags: ["managed-agents", "image", "integration-test"], + metadata: { test_run_id: suffix }, + }); + const imageUrl = "https://smith.langchain.com/og_image.png"; + const fixture = await createManagedAgentFixture(anthropic, suffix, { + system: "Describe images concisely.", + }); + try { + const stream = await anthropic.beta.sessions.events.stream( + fixture.session.id, + ); + await anthropic.beta.sessions.events.send(fixture.session.id, { + events: [ + { + type: "user.message", + content: [ + { type: "text", text: "Describe this image in one sentence." }, + { + type: "image", + source: { type: "url", url: imageUrl }, + }, + ], + }, + ], + } as any); + const events: any[] = []; + for await (const event of stream) { + events.push(event); + if (event.type === "session.status_idle") break; + } + expect(events.some((event) => event.type === "agent.message")).toBe( + true, + ); + } finally { + await fixture.cleanup(); + } + }); + }); +}); + +test("prepopulated invocation params are merged and runtime params override", async () => { + const { client, callSpy } = mockClient(); + + const wrappedClient = wrapAnthropic(new Anthropic(), { + client, + tracingEnabled: true, + metadata: { + ls_invocation_params: { top_k: 100, env: "test", team: "qa" }, + custom_key: "custom_value", + version: "1.0.0", + }, + }); + + await wrappedClient.messages.create({ + messages: [{ role: "user", content: "Say 'hello'" }], + model: "claude-haiku-4-5", + top_k: 40, // Should override prepopulated top_k=100 + max_tokens: 10, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + + expect(postCalls.length).toBeGreaterThan(0); + + // Get the POST call with run data (should have extra.metadata) + const postBody = parseRequestBody((postCalls[0][1] as any).body); + + // ls_invocation_params is in metadata, not in extra.invocation_params + const metadata = postBody.extra?.metadata; + const lsInvocationParams = metadata?.ls_invocation_params; + + // Runtime top_k should override prepopulated top_k + expect(lsInvocationParams?.top_k).toBe(40); + // Prepopulated params without conflicts should still be there + expect(lsInvocationParams?.env).toBe("test"); + expect(lsInvocationParams?.team).toBe("qa"); + + // Check that other metadata keys are preserved + expect(metadata?.custom_key).toBe("custom_value"); + expect(metadata?.version).toBe("1.0.0"); + + callSpy.mockClear(); +}); diff --git a/src/lib/tests/wrapped_anthropic_managed_agents.vitesttest.ts b/src/lib/tests/wrapped_anthropic_managed_agents.vitesttest.ts new file mode 100644 index 0000000..aab8007 --- /dev/null +++ b/src/lib/tests/wrapped_anthropic_managed_agents.vitesttest.ts @@ -0,0 +1,1287 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, test, expect, vi, it } from "vitest"; +import * as fs from "node:fs"; +import { wrapAnthropic } from "../wrappers/anthropic.js"; +import { mockClient } from "./utils/vitest_mock_client.js"; +import { asTree, getAssumedTreeFromCalls } from "./utils/tree.js"; + +function parseRequestBody(body: any) { + return body instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(body)) + : JSON.parse(body); +} + +function createAsyncIterable(items: T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const item of items) { + yield item; + } + }, + }; +} + +async function createReplayableAnthropic( + path: string, + options?: { + replaceTime?: boolean; + }, +): Promise<[anthropic: any, session: { id: string }]> { + const input = await fs.promises.readFile( + new URL(path, import.meta.url), + "utf-8", + ); + + const reviver = (() => { + const { replaceTime } = options ?? {}; + if (!replaceTime) return undefined; + + const testTime = new Date(); + let firstTime: Date | null = null; + return (k: string, v: any) => { + if ( + replaceTime && + ["processed_at", "created_at", "updated_at"].includes(k) + ) { + const parsedV = new Date(v); + firstTime ??= parsedV; + return new Date( + testTime.getTime() + (parsedV.getTime() - firstTime.getTime()), + ).toISOString(); + } + return v; + }; + })(); + + const logEvents: [ + methodName: + | "client.beta.sessions.create" + | "client.beta.sessions.events.send" + | "client.beta.sessions.events.stream", + args: any, + ][] = input + .split("\n") + .filter((line) => line.trim() !== "") + .map((line) => JSON.parse(line, reviver)); + + const streamEvents = logEvents + .filter( + ([methodName]) => methodName === "client.beta.sessions.events.stream", + ) + .map(([, args]) => args); + + const sessionRetrieve = logEvents.find( + ([methodName]) => methodName === "client.beta.sessions.create", + )?.[1]; + + return [ + { + messages: { create: vi.fn(), stream: vi.fn() }, + beta: { + sessions: { + retrieve: vi.fn(async () => sessionRetrieve), + events: { + stream: vi.fn(async () => createAsyncIterable(streamEvents)), + send: vi.fn(async () => ({})), + }, + }, + }, + } as any, + sessionRetrieve, + ]; +} + +describe("wrapAnthropic Claude Managed Agents", () => { + function createFakeAnthropic(events: any[]) { + const fakeEvents = { + stream: vi.fn(async () => createAsyncIterable(events)), + send: vi.fn(async (_sessionID: string, params: any) => ({ + data: params.events.map((event: any, index: number) => ({ + id: `sent_${index}`, + ...event, + })), + })), + }; + + return { + messages: { + create: vi.fn(), + stream: vi.fn(), + }, + beta: { + sessions: { + retrieve: vi.fn(async (sessionID: string) => ({ + id: sessionID, + agent: { + model: { id: "claude-opus-4-8", speed: "standard" }, + }, + })), + events: fakeEvents, + }, + }, + } as any; + } + + test("traces streamed managed agent events and aggregates outputs", async () => { + const { client, callSpy } = mockClient(); + const fakeAnthropic = createFakeAnthropic([ + { + id: "sevt_running", + type: "session.status_running", + processed_at: "2026-04-01T00:00:00Z", + }, + { + id: "sevt_user", + type: "user.message", + content: [{ type: "text", text: "Create fibonacci.txt" }], + processed_at: "2026-04-01T00:00:00.250Z", + }, + { + id: "sevt_span_start", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:00.500Z", + }, + { + id: "sevt_tool", + type: "agent.tool_use", + name: "bash", + input: { command: "python fibonacci.py" }, + processed_at: "2026-04-01T00:00:01Z", + }, + { + id: "sevt_span_end", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start", + model_usage: { + input_tokens: 10, + output_tokens: 5, + cache_creation_input_tokens: 2, + cache_read_input_tokens: 3, + }, + processed_at: "2026-04-01T00:00:01.500Z", + }, + { + id: "sevt_tool_result", + type: "agent.tool_result", + tool_use_id: "sevt_tool", + content: [{ type: "text", text: "ran script" }], + is_error: false, + processed_at: "2026-04-01T00:00:02Z", + }, + { + id: "sevt_span_start_2", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:02.500Z", + }, + { + id: "sevt_msg", + type: "agent.message", + content: [{ type: "text", text: "Created fibonacci.txt" }], + processed_at: "2026-04-01T00:00:03Z", + }, + { + id: "sevt_span_end_2", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start_2", + model_usage: { + input_tokens: 20, + output_tokens: 7, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 3, + }, + processed_at: "2026-04-01T00:00:03.500Z", + }, + { + id: "sevt_idle", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:04Z", + }, + ]); + const anthropic = wrapAnthropic(fakeAnthropic, { + client, + tracingEnabled: true, + }); + + const stream = await anthropic.beta.sessions.events.stream("sesn_123"); + const seenEvents = []; + for await (const event of stream) { + seenEvents.push(event); + if (event.type === "session.status_idle") break; + } + + await client.awaitPendingTraceBatches(); + + expect(seenEvents).toHaveLength(10); + expect(fakeAnthropic.beta.sessions.events.stream).toHaveBeenCalledWith( + "sesn_123", + ); + + const postBodies = callSpy.mock.calls + .filter((call: any) => (call[1] as any).method === "POST") + .map((call: any) => parseRequestBody((call[1] as any).body)); + expect(postBodies).toHaveLength(4); + const postBody = postBodies.find( + (body: any) => body.name === "ClaudeManagedAgent", + ); + expect(postBody).toBeDefined(); + expect(postBody.run_type).toBe("chain"); + expect(postBody.inputs).toMatchObject({ + session_id: "sesn_123", + messages: [{ role: "user", content: "Create fibonacci.txt" }], + }); + expect(postBody.extra.metadata).toMatchObject({ + ls_provider: "anthropic", + ls_model_type: "chat", + thread_id: "sesn_123", + }); + expect( + postBodies.find( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + )?.run_type, + ).toBe("llm"); + expect(postBodies.find((body: any) => body.name === "bash")?.run_type).toBe( + "tool", + ); + + const patchBodies = postBodies; + expect( + callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ), + ).toHaveLength(0); + const patchBody = patchBodies.find( + (body: any) => body.name === "ClaudeManagedAgent", + ); + + expect(patchBody.error).toBeUndefined(); + expect(patchBody.outputs.text).toBeUndefined(); + expect(patchBody.outputs.message).toBeUndefined(); + expect(patchBody.outputs.messages).toMatchObject([ + { role: "assistant", content: "Created fibonacci.txt" }, + ]); + expect(patchBody.outputs.tool_calls).toMatchObject([ + { type: "agent.tool_use", name: "bash" }, + ]); + expect(patchBody.outputs.status).toBe("session.status_idle"); + expect(patchBody.outputs.stop_reason).toEqual({ type: "end_turn" }); + expect(patchBody.outputs.usage_metadata).toBeUndefined(); + expect(patchBody.extra.metadata.usage_metadata).toBeUndefined(); + + const llmPatchBodies = patchBodies.filter( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + ); + expect(llmPatchBodies).toHaveLength(2); + const llmPatchBody = llmPatchBodies[0]; + const finalLlmPatchBody = llmPatchBodies[1]; + expect(llmPatchBody?.extra.metadata).toMatchObject({ + ls_provider: "anthropic", + ls_model_name: "claude-opus-4-8", + ls_model_type: "chat", + ls_invocation_params: { + session_id: "sesn_123", + model_config: { id: "claude-opus-4-8", speed: "standard" }, + }, + }); + expect(llmPatchBody?.outputs.tool_calls).toMatchObject([ + { type: "agent.tool_use", name: "bash" }, + ]); + expect(llmPatchBody?.outputs.messages).toEqual( + expect.arrayContaining([ + { + id: "sevt_tool", + role: "assistant", + content: [ + { + type: "tool_use", + id: "sevt_tool", + name: "bash", + input: { command: "python fibonacci.py" }, + }, + ], + processed_at: "2026-04-01T00:00:01Z", + }, + ]), + ); + expect(llmPatchBody?.outputs.usage_metadata).toMatchObject({ + input_tokens: 15, + output_tokens: 5, + total_tokens: 20, + input_token_details: { + ephemeral_5m_input_tokens: 2, + cache_read: 3, + }, + }); + expect(finalLlmPatchBody?.inputs.messages).toEqual( + expect.arrayContaining([ + { role: "user", content: "Create fibonacci.txt" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + id: "sevt_tool", + name: "bash", + input: { command: "python fibonacci.py" }, + }, + ], + }, + { + role: "tool", + tool_call_id: "sevt_tool", + content: [{ type: "text", text: "ran script" }], + is_error: false, + }, + ]), + ); + expect(finalLlmPatchBody?.outputs.messages).toMatchObject([ + { role: "assistant", content: "Created fibonacci.txt" }, + ]); + const toolPatchBody = patchBodies.find((body: any) => body.name === "bash"); + expect(toolPatchBody?.outputs.event).toMatchObject({ + type: "agent.tool_result", + tool_use_id: "sevt_tool", + }); + }); + + test("does not duplicate completed tool child runs across later turns", async () => { + const { client, callSpy } = mockClient(); + const fakeAnthropic = createFakeAnthropic([ + { + id: "sevt_user_1", + type: "user.message", + content: [{ type: "text", text: "Run first command" }], + processed_at: "2026-04-01T00:00:00Z", + }, + { + id: "sevt_span_start_1", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:00.100Z", + }, + { + id: "sevt_tool_a", + type: "agent.tool_use", + name: "bash", + input: { command: "echo a" }, + processed_at: "2026-04-01T00:00:00.200Z", + }, + { + id: "sevt_span_end_1", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start_1", + model_usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + processed_at: "2026-04-01T00:00:00.300Z", + }, + { + id: "sevt_tool_result_a", + type: "agent.tool_result", + tool_use_id: "sevt_tool_a", + content: [{ type: "text", text: "a" }], + is_error: false, + processed_at: "2026-04-01T00:00:00.400Z", + }, + { + id: "sevt_idle_1", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:00.500Z", + }, + { + id: "sevt_user_2", + type: "user.message", + content: [{ type: "text", text: "Run second command" }], + processed_at: "2026-04-01T00:00:01Z", + }, + { + id: "sevt_span_start_2", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:01.100Z", + }, + { + id: "sevt_tool_b", + type: "agent.tool_use", + name: "bash", + input: { command: "echo b" }, + processed_at: "2026-04-01T00:00:01.200Z", + }, + { + id: "sevt_span_end_2", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start_2", + model_usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + processed_at: "2026-04-01T00:00:01.300Z", + }, + { + id: "sevt_tool_result_b", + type: "agent.tool_result", + tool_use_id: "sevt_tool_b", + content: [{ type: "text", text: "b" }], + is_error: false, + processed_at: "2026-04-01T00:00:01.400Z", + }, + { + id: "sevt_idle_2", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:01.500Z", + }, + ]); + const anthropic = wrapAnthropic(fakeAnthropic, { + client, + tracingEnabled: true, + }); + + const stream = + await anthropic.beta.sessions.events.stream("sesn_multi_turn"); + for await (const _event of stream) { + // consume the full stream across multiple idle turn boundaries + } + await client.awaitPendingTraceBatches(); + + const toolPostBodies = callSpy.mock.calls + .filter((call: any) => (call[1] as any).method === "POST") + .map((call: any) => parseRequestBody((call[1] as any).body)) + .filter((body: any) => body.name === "bash" && body.run_type === "tool"); + expect(toolPostBodies).toHaveLength(2); + expect(toolPostBodies.map((body: any) => body.inputs.id).sort()).toEqual([ + "sevt_tool_a", + "sevt_tool_b", + ]); + }); + + test("traces web search tool use events as LLM tool calls and tool child runs", async () => { + const { client, callSpy } = mockClient(); + const fakeAnthropic = createFakeAnthropic([ + { + id: "sevt_user", + type: "user.message", + content: [{ type: "text", text: "Use web search" }], + processed_at: "2026-04-01T00:00:00Z", + }, + { + id: "sevt_span_start", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:00.500Z", + }, + { + id: "sevt_web_search", + type: "agent.tool_use", + name: "web_search", + input: { query: "LangSmith tracing" }, + processed_at: "2026-04-01T00:00:01Z", + }, + { + id: "sevt_span_end", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start", + model_usage: { + input_tokens: 12, + output_tokens: 6, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + processed_at: "2026-04-01T00:00:01.500Z", + }, + { + id: "sevt_web_search_result", + type: "agent.tool_result", + tool_use_id: "sevt_web_search", + content: [{ type: "text", text: "Web search result" }], + is_error: false, + processed_at: "2026-04-01T00:00:02Z", + }, + { + id: "sevt_idle", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:03Z", + }, + ]); + const anthropic = wrapAnthropic(fakeAnthropic, { + client, + tracingEnabled: true, + }); + + const stream = + await anthropic.beta.sessions.events.stream("sesn_web_search"); + for await (const event of stream) { + if (event.type === "session.status_idle") break; + } + await client.awaitPendingTraceBatches(); + + const postBodies = callSpy.mock.calls + .filter((call: any) => (call[1] as any).method === "POST") + .map((call: any) => parseRequestBody((call[1] as any).body)); + expect( + postBodies.find( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + )?.run_type, + ).toBe("llm"); + expect( + postBodies.find((body: any) => body.name === "web_search")?.run_type, + ).toBe("tool"); + + const patchBodies = postBodies; + expect( + callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ), + ).toHaveLength(0); + const llmPatchBody = patchBodies.find( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + ); + expect(llmPatchBody?.outputs.tool_calls).toMatchObject([ + { + type: "agent.tool_use", + name: "web_search", + }, + ]); + const toolPatchBody = patchBodies.find( + (body: any) => body.name === "web_search", + ); + expect(toolPatchBody?.outputs.event).toMatchObject({ + type: "agent.tool_result", + tool_use_id: "sevt_web_search", + }); + }); + + test("traces MCP tool use events as LLM tool calls and tool child runs", async () => { + const { client, callSpy } = mockClient(); + const fakeAnthropic = createFakeAnthropic([ + { + id: "sevt_user", + type: "user.message", + content: [{ type: "text", text: "Use the MCP tool" }], + processed_at: "2026-04-01T00:00:00Z", + }, + { + id: "sevt_span_start", + type: "span.model_request_start", + processed_at: "2026-04-01T00:00:00.500Z", + }, + { + id: "sevt_mcp_tool", + type: "agent.mcp_tool_use", + mcp_server_name: "test_mcp", + name: "search_docs", + input: { query: "LangSmith" }, + processed_at: "2026-04-01T00:00:01Z", + }, + { + id: "sevt_span_end", + type: "span.model_request_end", + is_error: false, + model_request_start_id: "sevt_span_start", + model_usage: { + input_tokens: 8, + output_tokens: 4, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + processed_at: "2026-04-01T00:00:01.500Z", + }, + { + id: "sevt_mcp_result", + type: "agent.mcp_tool_result", + mcp_tool_use_id: "sevt_mcp_tool", + content: [{ type: "text", text: "MCP result" }], + is_error: false, + processed_at: "2026-04-01T00:00:02Z", + }, + { + id: "sevt_idle", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:03Z", + }, + ]); + const anthropic = wrapAnthropic(fakeAnthropic, { + client, + tracingEnabled: true, + }); + + const stream = await anthropic.beta.sessions.events.stream("sesn_mcp"); + for await (const event of stream) { + if (event.type === "session.status_idle") break; + } + await client.awaitPendingTraceBatches(); + + const postBodies = callSpy.mock.calls + .filter((call: any) => (call[1] as any).method === "POST") + .map((call: any) => parseRequestBody((call[1] as any).body)); + expect( + postBodies.find( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + )?.run_type, + ).toBe("llm"); + expect( + postBodies.find((body: any) => body.name === "search_docs")?.run_type, + ).toBe("tool"); + + const patchBodies = postBodies; + expect( + callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ), + ).toHaveLength(0); + const llmPatchBody = patchBodies.find( + (body: any) => body.name === "ClaudeManagedAgentModelRequest", + ); + expect(llmPatchBody?.outputs.tool_calls).toMatchObject([ + { + type: "agent.mcp_tool_use", + mcp_server_name: "test_mcp", + name: "search_docs", + }, + ]); + const toolPatchBody = patchBodies.find( + (body: any) => body.name === "search_docs", + ); + expect(toolPatchBody?.outputs.event).toMatchObject({ + type: "agent.mcp_tool_result", + mcp_tool_use_id: "sevt_mcp_tool", + }); + }); + + test("records sent user events on an active managed agent stream run", async () => { + const { client, callSpy } = mockClient(); + const fakeAnthropic = createFakeAnthropic([ + { + id: "sevt_user", + type: "user.message", + content: [{ type: "text", text: "Create fibonacci.txt" }], + processed_at: "2026-04-01T00:00:00Z", + }, + { + id: "sevt_msg", + type: "agent.message", + content: [{ type: "text", text: "Done" }], + processed_at: "2026-04-01T00:00:01Z", + }, + { + id: "sevt_idle", + type: "session.status_idle", + stop_reason: { type: "end_turn" }, + processed_at: "2026-04-01T00:00:02Z", + }, + ]); + const anthropic = wrapAnthropic(fakeAnthropic, { + client, + tracingEnabled: true, + }); + + const stream = await anthropic.beta.sessions.events.stream("sesn_456"); + await anthropic.beta.sessions.events.send("sesn_456", { + events: [ + { + type: "user.message", + content: [{ type: "text", text: "Create fibonacci.txt" }], + }, + ], + }); + + for await (const _event of stream) { + // consume stream to finish the trace + } + await client.awaitPendingTraceBatches(); + + expect( + callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ), + ).toHaveLength(0); + const streamPostCalls = callSpy.mock.calls.filter((call: any) => { + if ((call[1] as any).method !== "POST") return false; + const body = parseRequestBody((call[1] as any).body); + return body.name === "ClaudeManagedAgent"; + }); + expect(streamPostCalls).toHaveLength(1); + const postBody = parseRequestBody((streamPostCalls[0][1] as any).body); + expect(postBody.inputs.events).toMatchObject([ + { + type: "user.message", + content: [{ type: "text", text: "Create fibonacci.txt" }], + }, + ]); + expect(postBody.inputs.messages).toEqual([ + { role: "user", content: "Create fibonacci.txt" }, + ]); + const sendPostCalls = callSpy.mock.calls.filter((call: any) => { + if ((call[1] as any).method !== "POST") return false; + const body = parseRequestBody((call[1] as any).body); + return body.name === "ClaudeManagedAgentSendEvents"; + }); + expect(sendPostCalls).toHaveLength(0); + }); + + it("custom tools", async () => { + const { client, callSpy } = mockClient(); + const [anthropic, session] = await createReplayableAnthropic( + "./test_data/anthropic_managed_custom_tools.jsonl", + ); + + const wrappedClient = wrapAnthropic(anthropic, { + client, + tracingEnabled: true, + }); + + const stream = await wrappedClient.beta.sessions.events.stream(session.id); + // consume stream + for await (const _ of stream) { + // noop + } + + await client.awaitPendingTraceBatches(); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + const expected = asTree((run) => { + run`ClaudeManagedAgent:0`( + { + run_type: "chain", + inputs: { + session_id: session.id, + messages: [ + { role: "user", content: "What's the weather in Prague?" }, + { + role: "tool", + content: [ + { text: "It's 23°C and sunny in Prague.", type: "text" }, + ], + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: + "It's currently **23°C and sunny** in Prague — a nice, warm day overall!", + }, + ], + }, + }, + run`ClaudeManagedAgentModelRequest:1`({ + run_type: "llm", + inputs: { + system: + "You are a helpful coding assistant. Write clean, well-documented code.", + messages: [ + { role: "user", content: "What's the weather in Prague?" }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "get_weather", + input: { location: "Prague" }, + }, + ], + }, + ], + }, + }), + run`get_weather:2`({ + run_type: "tool", + inputs: { + name: "get_weather", + input: { location: "Prague" }, + }, + outputs: { + content: [{ text: "It's 23°C and sunny in Prague.", type: "text" }], + }, + }), + run`ClaudeManagedAgentModelRequest:3`({ + run_type: "llm", + inputs: { + system: + "You are a helpful coding assistant. Write clean, well-documented code.", + messages: [ + { role: "user", content: "What's the weather in Prague?" }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "get_weather", + input: { location: "Prague" }, + }, + ], + }, + { + role: "tool", + content: [ + { text: "It's 23°C and sunny in Prague.", type: "text" }, + ], + is_error: false, + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: + "It's currently **23°C and sunny** in Prague — a nice, warm day overall!", + }, + ], + }, + }), + ); + }); + + expect(tree.nodes).toEqual(expected.nodes); + expect(tree.edges).toEqual(expected.edges); + expect(tree.data).toMatchObject(expected.data); + + const runs = Object.values(tree.data); + expect(runs.some((run) => run.name === "ClaudeManagedAgent")).toBe(true); + expect( + runs.some((run) => run.name === "ClaudeManagedAgentModelRequest"), + ).toBe(true); + }); + + it("builtin tools", async () => { + const { client, callSpy } = mockClient(); + const [anthropic, session] = await createReplayableAnthropic( + "./test_data/anthropic_managed_builtin_tools.jsonl", + ); + + const wrappedClient = wrapAnthropic(anthropic, { + client, + tracingEnabled: true, + }); + + const stream = await wrappedClient.beta.sessions.events.stream(session.id); + // consume stream + for await (const _ of stream) { + // noop + } + + await client.awaitPendingTraceBatches(); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + const expected = asTree((run) => { + run`ClaudeManagedAgent:0`( + { + run_type: "chain", + inputs: { + session_id: session.id, + messages: [ + { + role: "user", + content: "Call 'date' to get the current date time", + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: + "The current date and time is:\n\n**Monday, July 6, 2026, 16:26:37 UTC**", + }, + ], + }, + }, + run`ClaudeManagedAgentModelRequest:1`({ + run_type: "llm", + inputs: { + system: + "You are a helpful coding assistant. Write clean, well-documented code.", + messages: [ + { + role: "user", + content: "Call 'date' to get the current date time", + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "bash", + input: { command: "date" }, + }, + ], + }, + ], + }, + }), + run`bash:2`({ + run_type: "tool", + inputs: { + name: "bash", + input: { command: "date" }, + }, + outputs: { + content: [{ text: "Mon Jul 6 16:26:37 UTC 2026\n", type: "text" }], + }, + }), + run`ClaudeManagedAgentModelRequest:3`({ + run_type: "llm", + inputs: { + system: + "You are a helpful coding assistant. Write clean, well-documented code.", + messages: [ + { + role: "user", + content: "Call 'date' to get the current date time", + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "bash", + input: { command: "date" }, + }, + ], + }, + { + role: "tool", + content: [ + { text: "Mon Jul 6 16:26:37 UTC 2026\n", type: "text" }, + ], + is_error: false, + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: + "The current date and time is:\n\n**Monday, July 6, 2026, 16:26:37 UTC**", + }, + ], + }, + }), + ); + }); + + expect(tree.nodes).toEqual(expected.nodes); + expect(tree.edges).toEqual(expected.edges); + expect(tree.data).toMatchObject(expected.data); + + const runs = Object.values(tree.data); + expect(runs.some((run) => run.name === "ClaudeManagedAgent")).toBe(true); + expect( + runs.some((run) => run.name === "ClaudeManagedAgentModelRequest"), + ).toBe(true); + }); + + it("mcp tools", async () => { + const { client, callSpy } = mockClient(); + const [anthropic, session] = await createReplayableAnthropic( + "./test_data/anthropic_managed_mcp_tools.jsonl", + ); + + const wrappedClient = wrapAnthropic(anthropic, { + client, + tracingEnabled: true, + }); + + const stream = await wrappedClient.beta.sessions.events.stream(session.id); + // consume stream + for await (const _ of stream) { + // noop + } + + await client.awaitPendingTraceBatches(); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + const expected = asTree((run) => { + // First turn: interrupted as permission is required to use the MCP tool + run`ClaudeManagedAgent:0`( + { + run_type: "chain", + inputs: { + session_id: session.id, + messages: [ + { + role: "user", + content: + "How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa", + }, + ], + }, + error: expect.stringContaining("Interrupted: requires_action"), + }, + run`ClaudeManagedAgentModelRequest:1`({ + run_type: "llm", + inputs: { + system: + "Uses Exa's MCP to answer questions about the LangSmith SDK.", + messages: [ + { + role: "user", + content: + "How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa", + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "web_search_exa", + input: { + numResults: 5, + query: + "langchain-ai/langsmith-sdk GitHub repository stars", + }, + }, + ], + }, + ], + }, + }), + ); + + run`ClaudeManagedAgent:2`( + { + run_type: "chain", + inputs: { messages: [] }, + outputs: { + messages: [ + { + role: "assistant", + content: expect.stringContaining("langchain-ai/langsmith-sdk"), + }, + ], + }, + }, + run`web_search_exa:3`({ + run_type: "tool", + inputs: { + name: "web_search_exa", + input: { + numResults: 5, + query: "langchain-ai/langsmith-sdk GitHub repository stars", + }, + }, + outputs: { + content: [ + { + type: "text", + text: expect.stringContaining( + "Title: langchain-ai/langsmith-sdk - GitHub", + ), + }, + ], + }, + }), + run`ClaudeManagedAgentModelRequest:4`({ + run_type: "llm", + inputs: { + system: + "Uses Exa's MCP to answer questions about the LangSmith SDK.", + messages: [ + { + role: "user", + content: + "How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa", + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "web_search_exa", + input: { + numResults: 5, + query: + "langchain-ai/langsmith-sdk GitHub repository stars", + }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "text", + text: expect.stringContaining( + "Title: langchain-ai/langsmith-sdk - GitHub", + ), + }, + ], + is_error: false, + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: expect.stringContaining("approximately **936 stars**"), + }, + ], + }, + }), + ); + }); + + expect(tree.nodes).toEqual(expected.nodes); + expect(tree.edges).toEqual(expected.edges); + expect(tree.data).toMatchObject(expected.data); + + const runs = Object.values(tree.data); + expect(runs.some((run) => run.name === "ClaudeManagedAgent")).toBe(true); + expect( + runs.some((run) => run.name === "ClaudeManagedAgentModelRequest"), + ).toBe(true); + }); + + it("mcp tool rejection", async () => { + const { client, callSpy } = mockClient(); + const [anthropic, session] = await createReplayableAnthropic( + "./test_data/anthropic_managed_mcp_tools_reject.jsonl", + ); + + const wrappedClient = wrapAnthropic(anthropic, { + client, + tracingEnabled: true, + }); + + const stream = await wrappedClient.beta.sessions.events.stream(session.id); + // consume stream + for await (const _ of stream) { + // noop + } + + await client.awaitPendingTraceBatches(); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + + const expected = asTree((run) => { + run`ClaudeManagedAgent:0`( + { + run_type: "chain", + inputs: { + session_id: session.id, + messages: [ + { + role: "user", + content: + "How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa", + }, + ], + }, + error: expect.stringContaining("Interrupted: requires_action"), + }, + run`ClaudeManagedAgentModelRequest:1`({ + run_type: "llm", + inputs: { + system: + "Uses Exa's MCP to answer questions about the LangSmith SDK.", + messages: [ + { + role: "user", + content: + "How many stars does langchain-ai/langsmith-sdk have? Use MCP and Exa", + }, + ], + }, + outputs: { + messages: [ + { + role: "assistant", + content: "I'll look that up for you using Exa search.", + }, + { + role: "assistant", + content: [ + { + type: "tool_use", + name: "web_search_exa", + input: { + numResults: 5, + query: + "langchain-ai langsmith-sdk GitHub repository stars", + }, + }, + ], + }, + ], + }, + }), + ); + + run`ClaudeManagedAgent:2`( + { + run_type: "chain", + inputs: { messages: [] }, + outputs: { + messages: [ + { + role: "assistant", + content: expect.stringContaining("langchain-ai/langsmith-sdk"), + }, + ], + }, + }, + run`web_search_exa:3`({ + run_type: "tool", + inputs: { + name: "web_search_exa", + input: { + numResults: 5, + query: "langchain-ai langsmith-sdk GitHub repository stars", + }, + }, + outputs: { + content: [ + { + type: "text", + text: expect.stringContaining( + "Permission to use mcp__exa__web_search_exa has been rejected", + ), + }, + ], + }, + error: expect.stringContaining( + "Permission to use mcp__exa__web_search_exa has been rejected", + ), + }), + run`ClaudeManagedAgentModelRequest:4`({ run_type: "llm" }), + run`web_fetch:5`({ run_type: "tool" }), + run`ClaudeManagedAgentModelRequest:6`({ run_type: "llm" }), + ); + }); + + expect(tree.nodes).toEqual(expected.nodes); + expect(tree.edges).toEqual(expected.edges); + expect(tree.data).toMatchObject(expected.data); + + const runs = Object.values(tree.data); + expect(runs.some((run) => run.name === "ClaudeManagedAgent")).toBe(true); + expect( + runs.some((run) => run.name === "ClaudeManagedAgentModelRequest"), + ).toBe(true); + }); +}); diff --git a/src/lib/tests/wrapped_anthropic_mcp.test.ts b/src/lib/tests/wrapped_anthropic_mcp.test.ts new file mode 100644 index 0000000..2205308 --- /dev/null +++ b/src/lib/tests/wrapped_anthropic_mcp.test.ts @@ -0,0 +1,206 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { wrapAnthropic } from "../wrappers/anthropic.js"; +import { SECRET_PLACEHOLDER } from "../anonymizer/index.js"; +import { mockClient } from "./utils/mock_client.js"; + +const FAKE_TOKEN = "fake-token-for-tests-only"; + +function parseRequestBody(body: any) { + return body instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(body)) + : JSON.parse(body); +} + +/** Stub provider, so input processing runs without a network call. */ +function stubAnthropic(): any { + const message = { + id: "msg_stub", + type: "message", + role: "assistant", + model: "claude-haiku-4-5", + content: [{ type: "text", text: "hi" }], + stop_reason: "end_turn", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return { + messages: { + create: async () => message, + stream: async () => { + throw new Error("not used by these tests"); + }, + }, + }; +} + +/** The runs the SDK would have uploaded. */ +function uploadedRuns(callSpy: any): any[] { + return (callSpy.mock.calls as any[]) + .map(([, init]: any) => { + try { + return parseRequestBody(init?.body); + } catch { + return undefined; + } + }) + .filter(Boolean) + .flatMap((body: any) => body?.post ?? body?.patch ?? [body]) + .filter((run: any) => run?.inputs); +} + +async function traceCreate( + params: Record, + requestOptions?: Record, +) { + const { client, callSpy } = mockClient(); + const patched = wrapAnthropic(stubAnthropic(), { + client, + tracingEnabled: true, + }); + + const args: unknown[] = [ + { + model: "claude-haiku-4-5", + max_tokens: 16, + messages: [{ role: "user", content: "hi" }], + ...params, + }, + ]; + if (requestOptions !== undefined) args.push(requestOptions); + + await (patched.messages.create as any)(...args); + + const runs = uploadedRuns(callSpy); + return { runs, wire: JSON.stringify(runs) }; +} + +/** Traced provider params; a multi-argument call is recorded as `{ args: [...] }`. */ +function tracedParams(run: any): any { + return Array.isArray(run.inputs.args) ? run.inputs.args[0] : run.inputs; +} + +const mcpServers = (extra: Record = {}) => [ + { + type: "url", + url: "https://mcp.example.com/sse", + name: "example", + authorization_token: FAKE_TOKEN, + ...extra, + }, +]; + +describe("mcp_servers credentials are masked before tracing", () => { + test("authorization_token never reaches the uploaded run", async () => { + const { runs, wire } = await traceCreate({ mcp_servers: mcpServers() }); + + expect(wire).not.toContain(FAKE_TOKEN); + const server = runs[0].inputs.mcp_servers[0]; + expect(server.authorization_token).toBe(SECRET_PLACEHOLDER); + expect(server).toMatchObject({ + type: "url", + url: "https://mcp.example.com/sse", + name: "example", + }); + }); + + test("fields that are not explicitly allowed are masked too", async () => { + const { runs, wire } = await traceCreate({ + mcp_servers: mcpServers({ future_secret: "s3cret" }), + }); + + expect(wire).not.toContain("s3cret"); + expect(runs[0].inputs.mcp_servers[0].future_secret).toBe( + SECRET_PLACEHOLDER, + ); + }); + + test("the caller's params are left intact for the API call", async () => { + const servers = mcpServers(); + await traceCreate({ mcp_servers: servers }); + + expect(servers[0].authorization_token).toBe(FAKE_TOKEN); + }); + + test("masking also applies when a system prompt is present", async () => { + const { runs, wire } = await traceCreate({ + system: "be brief", + mcp_servers: mcpServers(), + }); + + expect(wire).not.toContain(FAKE_TOKEN); + expect(runs[0].inputs.mcp_servers[0].authorization_token).toBe( + SECRET_PLACEHOLDER, + ); + expect(runs[0].inputs.messages[0]).toEqual({ + role: "system", + content: "be brief", + }); + expect(runs[0].inputs.system).toBeUndefined(); + }); + + test.each([ + ["a non-array value", "not-an-array"], + ["null entries", [null]], + ["nested arrays", [["nested"]]], + ["an empty list", []], + ])("tolerates %s", async (_label, servers) => { + const { runs } = await traceCreate({ mcp_servers: servers }); + + expect(runs[0].inputs.mcp_servers).toEqual(servers); + }); +}); + +describe("masking survives every argument shape", () => { + test.each([ + ["langsmithExtra only", { langsmithExtra: { metadata: { foo: "bar" } } }], + ["an empty options object", {}], + ["real request options", { timeout: 5000 }], + ])("masks mcp_servers when called with %s", async (_label, options) => { + const { runs, wire } = await traceCreate( + { mcp_servers: mcpServers() }, + options, + ); + + expect(wire).not.toContain(FAKE_TOKEN); + expect(tracedParams(runs[0]).mcp_servers[0].authorization_token).toBe( + SECRET_PLACEHOLDER, + ); + }); + + test("masks credentials in the request options themselves", async () => { + const { runs, wire } = await traceCreate( + {}, + { headers: { Authorization: `Bearer ${FAKE_TOKEN}` } }, + ); + + expect(wire).not.toContain(FAKE_TOKEN); + expect(runs[0].inputs.args[1].headers).toEqual({ + Authorization: SECRET_PLACEHOLDER, + }); + }); + + test("the caller's arguments are left intact for the API call", async () => { + const servers = mcpServers(); + const options = { headers: { Authorization: `Bearer ${FAKE_TOKEN}` } }; + + await traceCreate({ mcp_servers: servers }, options); + + expect(servers[0].authorization_token).toBe(FAKE_TOKEN); + expect(options.headers.Authorization).toBe(`Bearer ${FAKE_TOKEN}`); + }); +}); + +describe("per-request transport overrides are masked", () => { + test.each(["extra_headers", "extra_body", "extra_query"])( + "%s keeps its key names but not its values", + async (key) => { + const { runs, wire } = await traceCreate({ + [key]: { Authorization: `Bearer ${FAKE_TOKEN}` }, + }); + + expect(wire).not.toContain(FAKE_TOKEN); + expect(runs[0].inputs[key]).toEqual({ + Authorization: SECRET_PLACEHOLDER, + }); + }, + ); +}); diff --git a/src/lib/tests/wrapped_gemini.int.test.ts b/src/lib/tests/wrapped_gemini.int.test.ts new file mode 100644 index 0000000..b359785 --- /dev/null +++ b/src/lib/tests/wrapped_gemini.int.test.ts @@ -0,0 +1,487 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ + +import { GoogleGenAI } from "@google/genai"; +import { wrapGemini } from "../wrappers/gemini.js"; +import { mockClient } from "./utils/mock_client.js"; +import { requiresProvider } from "./utils/markers.js"; +const { test } = requiresProvider; + +function parseRequestBody(body: any) { + return body instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(body)) + : JSON.parse(body); +} + +test("wrapGemini should return type compatible with GoogleGenAI", async () => { + const originalClient = new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }); + + const wrappedClient = wrapGemini(originalClient); + + expect(wrappedClient).toBeDefined(); +}); + +test("models.generateContent non-streaming", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }); + + const patchedClient = wrapGemini(originalClient, { + client, + tracingEnabled: true, + }); + + const response = await patchedClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say 'hello'", + }); + + await client.awaitPendingTraceBatches(); + + // TypeScript knows response should have certain properties + // Let's verify the basic response structure + expect(response).toBeDefined(); + + // Verify tracing was called + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify we made POST (create run) and PATCH (update run) calls + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + + callSpy.mockClear(); +}); + +test("models.generateContentStream streaming", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }); + + const patchedClient = wrapGemini(originalClient, { + client, + tracingEnabled: true, + }); + + // generateContentStream returns an async iterable + // Type: AsyncGenerator or AsyncIterable + const stream = await patchedClient.models.generateContentStream({ + model: "gemini-2.5-flash", + contents: "Count to 3", + }); + + // Collect chunks using for-await-of (async iteration) + const chunks: any[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + // Verify we got some chunks + expect(chunks.length).toBeGreaterThan(0); + + await client.awaitPendingTraceBatches(); + + // Verify tracing was called + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + callSpy.mockClear(); +}); + +test("wrapping same instance should throw", async () => { + const wrapped = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + ); + expect(() => wrapGemini(wrapped)).toThrowError(/already been wrapped/i); +}); + +test("should trace calls to langsmith", async () => { + const { client, callSpy } = mockClient(); + + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + await geminiClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say 'hello'", + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + const firstCall = callSpy.mock.calls[0]; + const request = firstCall[1] as any; + const body = parseRequestBody(request.body); + + expect(body.name).toBeDefined(); + expect(body.inputs).toBeDefined(); + expect(body.run_type).toBe("llm"); + + callSpy.mockClear(); +}); + +test("should include model in traced inputs", async () => { + const { client, callSpy } = mockClient(); + + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + await geminiClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say 'hello'", + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(postCalls.length).toBeGreaterThan(0); + + const body = parseRequestBody((postCalls[0][1] as any).body); + expect(body.inputs).toBeDefined(); + expect(body.inputs.model).toBe("gemini-2.5-flash"); + + callSpy.mockClear(); +}); + +test("should extract usage metadata in outputs", async () => { + const { client, callSpy } = mockClient(); + + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + await geminiClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say hello world", + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + // Find any call with usage_metadata in outputs + let usageMetadata; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const body = parseRequestBody(request.body); + + if (body.outputs && body.outputs.usage_metadata) { + usageMetadata = body.outputs.usage_metadata; + break; + } + } + + // Verify usage_metadata exists in outputs + expect(usageMetadata).toBeDefined(); + expect(usageMetadata.input_tokens).toBeGreaterThan(0); + expect(usageMetadata.output_tokens).toBeGreaterThan(0); + expect(usageMetadata.total_tokens).toBeGreaterThan(0); + + callSpy.mockClear(); +}); + +test("should extract usage metadata in extra.metadata.usage_metadata", async () => { + const { client, callSpy } = mockClient(); + + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + await geminiClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say hello world", + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + // Find any call with usage_metadata in extra.metadata.usage_metadata + let usageMetadata; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const body = parseRequestBody(request.body); + + if (body.extra?.metadata?.usage_metadata) { + usageMetadata = body.extra.metadata.usage_metadata; + break; + } + } + + // Verify usage_metadata exists in extra.metadata + expect(usageMetadata).toBeDefined(); + expect(usageMetadata.input_tokens).toBeGreaterThan(0); + expect(usageMetadata.output_tokens).toBeGreaterThan(0); + expect(usageMetadata.total_tokens).toBeGreaterThan(0); + + callSpy.mockClear(); +}); + +test("should handle function calling", async () => { + const { client, callSpy } = mockClient(); + + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + const toolDeclaration = { + name: "get_weather", + parametersJsonSchema: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }; + + const response = await geminiClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "What's the weather in San Francisco?", + config: { + tools: [{ functionDeclarations: [toolDeclaration] }], + }, + }); + + expect(response).toBeDefined(); + + await client.awaitPendingTraceBatches(); + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + + // Find the PATCH call + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + + if (patchCalls.length > 0) { + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const requestBody = (lastPatchCall[1] as any).body; + const body = + typeof requestBody === "string" ? JSON.parse(requestBody) : requestBody; + + // If function was called, verify it's in outputs + if (body.outputs?.tool_calls) { + expect(Array.isArray(body.outputs.tool_calls)).toBe(true); + expect(body.outputs.tool_calls.length).toBeGreaterThan(0); + } + } + + callSpy.mockClear(); +}); + +test("should handle image input", async () => { + const { client, callSpy } = mockClient(); + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + // Simple 1x1 red pixel PNG in base64 + const redPixelBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="; + + const response = await geminiClient.models.generateContent({ + model: "gemini-2.5-flash-image", + contents: [ + { + role: "user", + parts: [ + { + text: "What do you see in this image? Just say 'red pixel' if you see it.", + }, + { + inlineData: { + mimeType: "image/png", + data: redPixelBase64, + }, + }, + ], + }, + ], + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + expect(response).toBeDefined(); + expect(response.text).toBeDefined(); + + // Verify the image was included in the traced inputs + let foundImageInput = false; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const body = parseRequestBody(request.body); + + if (body.inputs) { + const inputStr = JSON.stringify(body.inputs); + if (inputStr.includes("inlineData") || inputStr.includes("image/png")) { + foundImageInput = true; + break; + } + } + } + + expect(foundImageInput).toBe(true); + + callSpy.mockClear(); +}); + +test("should handle image generation output", async () => { + const { client, callSpy } = mockClient(); + const geminiClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + }, + ); + + // Use gemini-2.5-flash-image model which supports image generation + const response = await geminiClient.models.generateContent({ + model: "gemini-2.5-flash-image", + contents: [ + { + role: "user", + parts: [{ text: "Generate a simple red square" }], + }, + ], + config: { + responseModalities: ["Image"], + }, + }); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + expect(response).toBeDefined(); + + // Check if response contains image data + if (response.candidates?.[0]?.content?.parts) { + const parts = response.candidates[0].content.parts; + const hasImage = parts.some( + (part: any) => + part.inlineData?.mimeType?.startsWith("image/") || + part.fileData?.mimeType?.startsWith("image/"), + ); + expect(hasImage).toBe(true); + } + + // Verify the image output is traced + let foundImageOutput = false; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const body = parseRequestBody(request.body); + + if (body.outputs) { + const outputStr = JSON.stringify(body.outputs); + if (outputStr.includes("inlineData") || outputStr.includes("image/")) { + foundImageOutput = true; + break; + } + } + } + + expect(foundImageOutput).toBe(true); + + callSpy.mockClear(); +}); + +test("prepopulated invocation params are passed through", async () => { + const { client, callSpy } = mockClient(); + + const wrappedClient = wrapGemini( + new GoogleGenAI({ + apiKey: process.env.GEMINI_API_KEY || "test-key", + }), + { + client, + tracingEnabled: true, + metadata: { + ls_invocation_params: { env: "test", team: "qa" }, + custom_key: "custom_value", + version: "1.0.0", + }, + }, + ); + + await wrappedClient.models.generateContent({ + model: "gemini-2.5-flash", + contents: "Say 'hello'", + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + + expect(postCalls.length).toBeGreaterThan(0); + + // Get the POST call with run data (should have extra.metadata) + const postBody = parseRequestBody((postCalls[0][1] as any).body); + + // ls_invocation_params is in metadata, not in extra.invocation_params + const metadata = postBody.extra?.metadata; + const lsInvocationParams = metadata?.ls_invocation_params; + + // Should have prepopulated params (Gemini doesn't extract runtime params) + expect(lsInvocationParams?.env).toBe("test"); + expect(lsInvocationParams?.team).toBe("qa"); + + // Check that other metadata keys are preserved + expect(metadata?.custom_key).toBe("custom_value"); + expect(metadata?.version).toBe("1.0.0"); + + callSpy.mockClear(); +}); diff --git a/src/lib/tests/wrapped_gemini.vitesttest.ts b/src/lib/tests/wrapped_gemini.vitesttest.ts new file mode 100644 index 0000000..2432007 --- /dev/null +++ b/src/lib/tests/wrapped_gemini.vitesttest.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "vitest"; +import { createGeminiUsageMetadata } from "../wrappers/gemini.utils.js"; +import type { UsageMetadata } from "@google/genai"; +describe("create usage metadata", () => { + it("basic usage", () => { + const usage: UsageMetadata = { + promptTokenCount: 10, + responseTokenCount: 20, + totalTokenCount: 30, + }; + + const result = createGeminiUsageMetadata(usage); + + expect(result).toEqual({ + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + input_token_details: { + cache_read: undefined, + cache_read_over_200k: undefined, + over_200k: 0, + }, + output_token_details: { + reasoning: undefined, + over_200k: undefined, + }, + }); + }); + + it("includes cached tokens", () => { + const usage: UsageMetadata = { + promptTokenCount: 10, + responseTokenCount: 20, + cachedContentTokenCount: 5, + totalTokenCount: 30, + }; + + const result = createGeminiUsageMetadata(usage); + expect(result).toEqual({ + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + input_token_details: { + cache_read: 5, + cache_read_over_200k: 0, + over_200k: 0, + }, + output_token_details: { + reasoning: undefined, + over_200k: undefined, + }, + }); + }); + + it("includes cached tokens over 200k", () => { + const usage: UsageMetadata = { + promptTokenCount: 1_000_000, + responseTokenCount: 500_000, + cachedContentTokenCount: 250_000, + totalTokenCount: 1_500_000, + }; + + const result = createGeminiUsageMetadata(usage); + expect(result).toEqual({ + input_tokens: 1_000_000, + output_tokens: 500_000, + total_tokens: 1_500_000, + input_token_details: { + cache_read: 200_000, + cache_read_over_200k: 50_000, + over_200k: 550_000, + }, + output_token_details: { + reasoning: undefined, + over_200k: undefined, + }, + }); + }); + + it("includes reasoning tokens", () => { + const usage: UsageMetadata = { + promptTokenCount: 10, + responseTokenCount: 20, + thoughtsTokenCount: 15, + totalTokenCount: 30, + }; + + const result = createGeminiUsageMetadata(usage); + + expect(result).toEqual({ + input_tokens: 10, + output_tokens: 20, + total_tokens: 30, + input_token_details: { + cache_read: undefined, + cache_read_over_200k: undefined, + over_200k: 0, + }, + output_token_details: { + reasoning: 15, + over_200k: undefined, + }, + }); + }); +}); diff --git a/src/lib/tests/wrapped_openai.int.test.ts b/src/lib/tests/wrapped_openai.int.test.ts new file mode 100644 index 0000000..fba0e73 --- /dev/null +++ b/src/lib/tests/wrapped_openai.int.test.ts @@ -0,0 +1,1190 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-process-env */ +import { AzureOpenAI, OpenAI } from "openai"; +import { wrapOpenAI } from "../wrappers/index.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; +import { zodResponseFormat, zodTextFormat } from "openai/helpers/zod"; +import { z } from "zod"; +import { UsageMetadata } from "../schemas.js"; +import fs from "fs"; +import { requiresProvider } from "./utils/markers.js"; +const { test, describe } = requiresProvider; + +function parseRequestBody(body: any) { + return body instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(body)) + : JSON.parse(body); +} + +test("wrapOpenAI should return type compatible with OpenAI", async () => { + let originalClient = new OpenAI(); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + originalClient = wrapOpenAI(originalClient); + + expect(true).toBe(true); +}); + +test("chat.completions", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new OpenAI(); + const patchedClient = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + // invoke + const original = await originalClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + }); + + const patched = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + }); + + expect(patched.choices).toEqual(original.choices); + + const response = await patchedClient.chat.completions + .create({ + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + }) + .asResponse(); + + expect(response.ok).toBe(true); + + // stream + const originalStream = await originalClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }); + + const originalChoices: unknown[] = []; + for await (const chunk of originalStream) { + originalChoices.push(chunk.choices); + } + + const patchedStream = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }); + + const patchedChoices: unknown[] = []; + for await (const chunk of patchedStream) { + patchedChoices.push(chunk.choices); + // @ts-expect-error Should type check streamed output + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _test = chunk.invalidPrompt; + } + + expect(patchedChoices).toEqual(originalChoices); + + await client.awaitPendingTraceBatches(); + + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + tokenEvents.forEach((event: any) => { + expect(event.name).toBe("new_token"); + expect(event.kwargs).toBeUndefined(); + expect(event.time).toBeDefined(); + }); + + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + callSpy.mockClear(); + + const patchedStreamToBreak = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'hello world hello again'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of patchedStreamToBreak) { + break; + } + + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + callSpy.mockClear(); + + const patchedStreamWithMetadata = await patchedClient.chat.completions.create( + { + messages: [{ role: "user", content: `Say 'foo'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }, + { + langsmithExtra: { + metadata: { + thing1: "thing2", + }, + }, + }, + ); + + const patchedChoices2: unknown[] = []; + for await (const chunk of patchedStreamWithMetadata) { + patchedChoices2.push(chunk.choices); + // @ts-expect-error Should type check streamed output + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _test = chunk.invalidPrompt; + } + + expect(patchedChoices2).toEqual(originalChoices); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of callSpy.mock.calls) { + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + callSpy.mockClear(); +}); + +test("prepopulated invocation params are merged and runtime params override", async () => { + const { client, callSpy } = mockClient(); + + const wrappedClient = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + metadata: { + ls_invocation_params: { seed: 100, env: "test", team: "qa" }, + custom_key: "custom_value", + version: "1.0.0", + }, + }); + + await wrappedClient.chat.completions.create({ + messages: [{ role: "user", content: "Say 'hello'" }], + model: "gpt-4.1-nano", + seed: 42, // Should override prepopulated seed=100 + store: true, + metadata: { + request_key: "request_value", + ls_model_name: "should-not-override", + }, + }); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const postCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + + expect(postCalls.length).toBeGreaterThan(0); + + // Get the POST call with run data (should have extra.metadata) + const postBody = parseRequestBody((postCalls[0][1] as any).body); + + // ls_invocation_params is in metadata, not in extra.invocation_params + const metadata = postBody.extra?.metadata; + const lsInvocationParams = metadata?.ls_invocation_params; + + // Runtime seed should override prepopulated seed + expect(lsInvocationParams?.seed).toBe(42); + // Prepopulated params without conflicts should still be there + expect(lsInvocationParams?.env).toBe("test"); + expect(lsInvocationParams?.team).toBe("qa"); + + // Check that other metadata keys are preserved + expect(metadata?.custom_key).toBe("custom_value"); + expect(metadata?.version).toBe("1.0.0"); + + // OpenAI request metadata is copied to LangSmith run metadata + expect(metadata?.request_key).toBe("request_value"); + expect(metadata?.ls_model_name).toBe("gpt-4.1-nano"); + expect(lsInvocationParams?.metadata).toBeUndefined(); + + callSpy.mockClear(); +}); + +test("chat completions with tool calling", async () => { + const { client, callSpy } = mockClient(); + + const originalClient = new OpenAI(); + const patchedClient = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + const removeToolCallId = ( + choices: + | OpenAI.ChatCompletion.Choice[] + | OpenAI.ChatCompletionChunk.Choice[][], + ) => { + if (Array.isArray(choices[0])) { + return (choices as OpenAI.ChatCompletionChunk.Choice[][]).map( + (choices) => { + return choices.map((choice) => { + choice.delta.tool_calls = choice.delta.tool_calls?.map( + (toolCall) => { + const { id, ...rest } = toolCall; + return rest; + }, + ) as any; + return choice; + }); + }, + ); + } else { + return (choices as OpenAI.ChatCompletion.Choice[]).map((choice) => { + choice.message.tool_calls = choice.message.tool_calls?.map( + (toolCall) => { + const { id, ...rest } = toolCall; + return rest; + }, + ) as any; + return choice; + }); + } + }; + + const toolDefinition = [ + { + type: "function" as const, + function: { + name: "get_current_weather", + description: "Get the current weather in a given location", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "The city and only the city, e.g. San Francisco", + }, + }, + required: ["location"], + }, + }, + }, + ]; + + // invoke + const original = await originalClient.chat.completions.create({ + messages: [{ role: "user", content: `What is the current weather in SF?` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + tools: toolDefinition, + tool_choice: { + type: "function", + function: { name: "get_current_weather" }, + }, + }); + + const patched = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `What is the current weather in SF?` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + tools: toolDefinition, + tool_choice: { + type: "function", + function: { name: "get_current_weather" }, + }, + }); + + expect(removeToolCallId(patched.choices)).toEqual( + removeToolCallId(original.choices), + ); + + // stream + const originalStream = await originalClient.chat.completions.create({ + messages: [{ role: "user", content: `What is the current weather in SF?` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + tools: toolDefinition, + tool_choice: { + type: "function", + function: { name: "get_current_weather" }, + }, + stream: true, + }); + + const originalChoices: any[] = []; + for await (const chunk of originalStream) { + originalChoices.push(chunk.choices); + } + + const patchedStream = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `What is the current weather in SF?` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + tools: toolDefinition, + tool_choice: { + type: "function", + function: { name: "get_current_weather" }, + }, + stream: true, + }); + + const patchedChoices: any[] = []; + for await (const chunk of patchedStream) { + patchedChoices.push(chunk.choices); + // @ts-expect-error Should type check streamed output + const _test = chunk.invalidPrompt; + } + + expect(removeToolCallId(patchedChoices)).toEqual( + removeToolCallId(originalChoices), + ); + await client.awaitPendingTraceBatches(); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify token events were logged for tool calling stream + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + tokenEvents.forEach((event: any) => { + expect(event.name).toBe("new_token"); + expect(event.kwargs).toBeUndefined(); + expect(event.time).toBeDefined(); + }); + + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + } + callSpy.mockClear(); + + const patchedStream2 = await patchedClient.chat.completions.create( + { + messages: [ + { role: "user", content: `What is the current weather in SF?` }, + ], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + tools: toolDefinition, + tool_choice: { + type: "function", + function: { name: "get_current_weather" }, + }, + stream: true, + }, + { + langsmithExtra: { + metadata: { + thing1: "thing2", + }, + }, + }, + ); + + const patchedChoices2: any[] = []; + for await (const chunk of patchedStream2) { + patchedChoices2.push(chunk.choices); + // @ts-expect-error Should type check streamed output + const _test = chunk.invalidPrompt; + } + + expect(removeToolCallId(patchedChoices2)).toEqual( + removeToolCallId(originalChoices), + ); + await client.awaitPendingTraceBatches(); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + thing1: "thing2", + ls_model_name: "gpt-4.1-nano", + ls_model_type: "chat", + ls_provider: "openai", + ls_temperature: 0, + }); + } + callSpy.mockClear(); +}); + +test.skip("with initialization time config", async () => { + const patchedClient = wrapOpenAI(new OpenAI(), { + project_name: "alternate_project", + metadata: { + customKey: "customVal", + }, + }); + const patchedStream = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `What is the current weather in SF?` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }); + + const patchedChoices: unknown[] = []; + for await (const chunk of patchedStream) { + patchedChoices.push(chunk.choices); + // @ts-expect-error Should type check streamed output + const _test = chunk.invalidPrompt; + } +}); + +test.skip("no tracing with env var unset", async () => { + process.env.LANGCHAIN_TRACING_V2 = undefined; + process.env.LANGSMITH_TRACING_V2 = undefined; + const patchedClient = wrapOpenAI(new OpenAI()); + const patched = await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: `Say 'bazqux'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + }); + expect(patched).toBeDefined(); +}); + +test("wrapping same instance", async () => { + const wrapped = wrapOpenAI(new OpenAI()); + expect(() => wrapOpenAI(wrapped)).toThrowError( + "This instance of OpenAI client has been already wrapped once.", + ); +}); + +test("chat extra name", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + await openai.chat.completions.create( + { + messages: [{ role: "user", content: `Say 'red'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + }, + { langsmithExtra: { name: "red", metadata: { customKey: "red" } } }, + ); + + const stream = await openai.chat.completions.create( + { + messages: [{ role: "user", content: `Say 'green'` }], + temperature: 0, + seed: 42, + model: "gpt-4.1-nano", + stream: true, + }, + { langsmithExtra: { name: "green", metadata: { customKey: "green" } } }, + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of stream) { + // pass + } + + expect( + await getAssumedTreeFromCalls(callSpy.mock.calls, client), + ).toMatchObject({ + nodes: ["red:0", "green:1"], + edges: [], + data: { + "red:0": { + name: "red", + extra: { metadata: { customKey: "red" } }, + outputs: { + choices: [ + { + index: 0, + message: { role: "assistant", content: expect.any(String) }, + }, + ], + }, + }, + "green:1": { + name: "green", + extra: { metadata: { customKey: "green" } }, + outputs: { + choices: [ + { + index: 0, + message: { role: "assistant", content: expect.any(String) }, + }, + ], + }, + }, + }, + }); +}); + +test("chat.completions.parse", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + await openai.chat.completions.parse({ + model: "gpt-4.1-nano", + temperature: 0, + messages: [ + { + role: "user", + content: "I am Jacob", + }, + ], + response_format: zodResponseFormat( + z.object({ + name: z.string(), + }), + "name", + ), + }); + + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + ls_model_name: "gpt-4.1-nano", + ls_model_type: "chat", + ls_provider: "openai", + ls_temperature: 0, + }); + } + callSpy.mockClear(); +}); + +test("responses.create without storage traces creation but not failed retrieval", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + // Create a response (this should be traced) + const createResponse = await openai.responses.create({ + model: "gpt-5-nano", + store: false, + reasoning: { + effort: "low", + }, + metadata: { + request_key: "request_value", + }, + input: [ + { + role: "user", + content: [{ type: "input_text", text: "What is 2+2?" }], + }, + ], + }); + + expect(createResponse).toBeDefined(); + expect(createResponse.id).toBeDefined(); + await client.awaitPendingTraceBatches(); + + // Verify that create was traced + const createCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "POST", + ); + expect(createCalls.length).toBeGreaterThanOrEqual(1); + + const createCallCount = callSpy.mock.calls.length; + + // Retrieve the response (this should NOT be traced) + await expect( + openai.responses.retrieve(createResponse.id), + ).rejects.toMatchObject({ + status: 404, + message: expect.stringContaining(createResponse.id), + }); + await client.awaitPendingTraceBatches(); + + // Verify that retrieve did NOT add any new tracing calls + expect(callSpy.mock.calls.length).toBe(createCallCount); + + // Verify the create call had proper metadata + for (const call of createCalls) { + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + request_key: "request_value", + ls_model_name: "gpt-5-nano", + ls_model_type: "chat", + ls_provider: "openai", + ls_invocation_params: { + reasoning: { + effort: "low", + }, + }, + }); + } + const updateCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(updateCalls.length).toBeGreaterThanOrEqual(1); + for (const call of updateCalls) { + const body = parseRequestBody((call[1] as any).body); + expect(body.outputs.usage_metadata).toBeDefined(); + expect(body.outputs.usage_metadata.input_tokens).toBeGreaterThan(0); + expect(body.outputs.usage_metadata.output_tokens).toBeGreaterThan(0); + expect(body.outputs.usage_metadata.total_tokens).toBeGreaterThan(0); + } + + callSpy.mockClear(); +}); + +test("responses.create streaming", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const stream = await openai.responses.create({ + model: "gpt-4.1-nano", + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Say hello" }], + }, + ], + stream: true, + }); + + const chunks: unknown[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + await client.awaitPendingTraceBatches(); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify token events were logged + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + const lastPatchCall = patchCalls[patchCalls.length - 1]; + const body = parseRequestBody((lastPatchCall[1] as any).body); + + expect(body.events).toBeDefined(); + const tokenEvents = body.events.filter( + (event: any) => event.name === "new_token", + ); + expect(tokenEvents.length).toBeGreaterThan(0); + + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + ls_model_name: "gpt-4.1-nano", + ls_model_type: "chat", + ls_provider: "openai", + }); + } + for (const call of patchCalls) { + const body = parseRequestBody((call[1] as any).body); + expect(body.outputs.usage_metadata).toBeDefined(); + expect(body.outputs.usage_metadata.input_tokens).toBeGreaterThan(0); + expect(body.outputs.usage_metadata.output_tokens).toBeGreaterThan(0); + expect(body.outputs.usage_metadata.total_tokens).toBeGreaterThan(0); + } + callSpy.mockClear(); +}); + +test("responses.parse", async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const response = await openai.responses.parse({ + model: "gpt-4.1-nano", + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Say hello" }], + }, + ], + text: { + format: zodTextFormat( + z.object({ + response: z.string(), + }), + "response", + ), + }, + }); + expect(response).toBeDefined(); + expect(response.output_parsed).toBeDefined(); + expect(typeof response.output_parsed?.response).toBe("string"); + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + ls_model_name: "gpt-4.1-nano", + ls_model_type: "chat", + ls_provider: "openai", + }); + } + callSpy.mockClear(); +}); + +test("responses.parse streaming", async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const stream = openai.responses.stream({ + model: "gpt-4.1-nano", + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Say hello" }], + }, + ], + text: { + format: zodTextFormat( + z.object({ + response: z.string(), + }), + "response", + ), + }, + }); + const chunks: unknown[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.length).toBeGreaterThan(0); + expect(callSpy.mock.calls.length).toBeGreaterThan(0); + for (const call of callSpy.mock.calls) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(["POST", "PATCH"]).toContain((call[1] as any)["method"]); + const body = parseRequestBody((call[1] as any).body); + expect(body.extra.metadata).toMatchObject({ + ls_model_name: "gpt-4.1-nano", + ls_model_type: "chat", + ls_provider: "openai", + }); + } + callSpy.mockClear(); +}); + +test("responses other methods (untraced)", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + }); + + // Test that other responses methods exist and are preserved + expect(openai.responses.inputItems).toBeDefined(); + expect(typeof openai.responses.create).toBe("function"); + expect(typeof openai.responses.retrieve).toBe("function"); + expect(typeof openai.responses.delete).toBe("function"); + expect(typeof openai.responses.parse).toBe("function"); + expect(typeof openai.responses.stream).toBe("function"); + expect(typeof openai.responses.cancel).toBe("function"); + + // Verify that non-create methods don't generate tracing calls + const initialCallCount = callSpy.mock.calls.length; + + // These should exist and be accessible without generating tracing calls + expect(openai.responses.inputItems).toBeDefined(); + expect(callSpy.mock.calls.length).toBe(initialCallCount); + + callSpy.mockClear(); +}); + +test("chat.completions other methods (untraced)", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + // Test that all chat.completions methods exist and are preserved + expect(typeof openai.chat.completions.create).toBe("function"); + expect(typeof openai.chat.completions.parse).toBe("function"); + expect(typeof openai.chat.completions.retrieve).toBe("function"); + expect(typeof openai.chat.completions.update).toBe("function"); + expect(typeof openai.chat.completions.list).toBe("function"); + expect(typeof openai.chat.completions.delete).toBe("function"); + expect(typeof openai.chat.completions.runTools).toBe("function"); + expect(typeof openai.chat.completions.stream).toBe("function"); + + // Verify that non-traced methods don't generate tracing calls + const initialCallCount = callSpy.mock.calls.length; + + // These methods should exist and be accessible without generating tracing calls + expect(typeof openai.chat.completions.retrieve).toBe("function"); + expect(typeof openai.chat.completions.list).toBe("function"); + expect(callSpy.mock.calls.length).toBe(initialCallCount); + + callSpy.mockClear(); +}); + +test("beta methods preserved", async () => { + const { client, callSpy } = mockClient(); + + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + // Test that beta namespace is preserved + expect(openai.beta).toBeDefined(); + + // Test that all beta methods are accessible (they may not all exist depending on OpenAI SDK version) + if (openai.beta.assistants) { + expect(openai.beta.assistants).toBeDefined(); + } + + if (openai.beta.threads) { + expect(openai.beta.threads).toBeDefined(); + } + + // Verify that beta methods don't generate unexpected tracing calls + const initialCallCount = callSpy.mock.calls.length; + + // Accessing beta should not generate tracing calls + expect(openai.beta).toBeDefined(); + expect(callSpy.mock.calls.length).toBe(initialCallCount); + + callSpy.mockClear(); +}); + +const usageMetadataTestCases = [ + { + description: "stream", + params: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "howdy" }], + stream: true, + stream_options: { include_usage: true }, + }, + expectUsageMetadata: true, + }, + { + description: "stream no usage", + params: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "howdy" }], + stream: true, + }, + expectUsageMetadata: false, + }, + { + description: "default", + params: { + model: "gpt-4o-mini", + messages: [{ role: "user", content: "howdy" }], + }, + expectUsageMetadata: true, + }, + { + description: "reasoning", + params: { + model: "o1", + messages: [ + { + role: "user", + content: + "Write a bash script that takes a matrix represented as a string with format '[1,2],[3,4],[5,6]' and prints the transpose in the same format.", + }, + ], + }, + expectUsageMetadata: true, + checkReasoningTokens: true, + }, + // just test flex as priority can randomly downgrade + { + description: "flex service tier", + params: { + model: "gpt-5-nano", + messages: [{ role: "user", content: "howdy" }], + service_tier: "flex", + }, + expectUsageMetadata: true, + checkServiceTier: "flex", + }, +]; + +test("Azure OpenAI provider detection", async () => { + if (!process.env.AZURE_OPENAI_API_KEY) { + return; + } + const { client, callSpy } = mockClient(); + + const azureClient = new AzureOpenAI({ + apiKey: process.env.AZURE_OPENAI_API_KEY, + apiVersion: process.env.AZURE_OPENAI_API_VERSION, + endpoint: process.env.AZURE_OPENAI_ENDPOINT, + }); + + const patchedClient = wrapOpenAI(azureClient, { + client, + tracingEnabled: true, + }); + + await patchedClient.chat.completions.create({ + messages: [{ role: "user", content: "Say 'hello'" }], + temperature: 0, + seed: 42, + model: "gpt-4o-mini", + }); + + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Check that the provider is set to "azure_openai" in the request + for (const call of callSpy.mock.calls) { + const body = parseRequestBody((call[1] as any).body); + if (body.extra && body.extra.metadata) { + expect(body.extra.metadata.ls_provider).toBe("azure"); + } + } + + callSpy.mockClear(); +}); + +describe("Usage Metadata Tests", () => { + usageMetadataTestCases.forEach( + ({ + description, + params, + expectUsageMetadata, + checkReasoningTokens, + checkServiceTier, + }) => { + // The LLM gateway injects usage into streams OpenAI leaves bare. + const run = + !expectUsageMetadata && process.env.OPENAI_BASE_URL ? it.skip : it; + run(`should handle ${description}`, async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + tracingEnabled: true, + client, + }); + + const requestParams = { ...params }; + + let oaiUsage: OpenAI.CompletionUsage | undefined; + if (requestParams.stream) { + const stream = await openai.chat.completions.create( + requestParams as OpenAI.ChatCompletionCreateParamsStreaming, + ); + for await (const chunk of stream) { + if (expectUsageMetadata && chunk.usage) { + oaiUsage = chunk.usage; + } + } + } else { + const res = await openai.chat.completions.create( + requestParams as OpenAI.ChatCompletionCreateParams, + ); + oaiUsage = (res as OpenAI.ChatCompletion).usage; + } + await client.awaitPendingTraceBatches(); + + let usageMetadata: UsageMetadata | undefined; + const requestBodies: any = {}; + for (const call of callSpy.mock.calls) { + const request = call[1] as any; + const requestBody = parseRequestBody(request.body); + if (request.method === "POST") { + requestBodies["post"] = [requestBody]; + } + if (request.method === "PATCH") { + requestBodies["patch"] = [requestBody]; + } + if (requestBody.outputs && requestBody.outputs.usage_metadata) { + usageMetadata = requestBody.outputs.usage_metadata; + break; + } + } + + if (expectUsageMetadata) { + expect(usageMetadata).not.toBeUndefined(); + expect(usageMetadata).not.toBeNull(); + expect(oaiUsage).not.toBeUndefined(); + expect(oaiUsage).not.toBeNull(); + expect(usageMetadata!.input_tokens).toEqual(oaiUsage!.prompt_tokens); + expect(usageMetadata!.output_tokens).toEqual( + oaiUsage!.completion_tokens, + ); + expect(usageMetadata!.total_tokens).toEqual(oaiUsage!.total_tokens); + + if (checkReasoningTokens) { + expect(usageMetadata!.output_token_details).not.toBeUndefined(); + expect( + usageMetadata!.output_token_details!.reasoning, + ).not.toBeUndefined(); + expect(usageMetadata!.output_token_details!.reasoning).toEqual( + oaiUsage!.completion_tokens_details?.reasoning_tokens, + ); + } + + if (checkServiceTier) { + expect(usageMetadata!.input_token_details).not.toBeUndefined(); + expect(usageMetadata!.output_token_details).not.toBeUndefined(); + expect( + usageMetadata?.input_token_details?.[checkServiceTier], + ).not.toBeUndefined(); + expect( + usageMetadata?.output_token_details?.[checkServiceTier], + ).not.toBeUndefined(); + expect( + usageMetadata?.input_token_details?.[checkServiceTier], + ).toBeGreaterThan(0); + expect( + usageMetadata?.output_token_details?.[checkServiceTier], + ).toBeGreaterThan(0); + } + } else { + expect(usageMetadata).toBeUndefined(); + expect(oaiUsage).toBeUndefined(); + } + + if (process.env.WRITE_TOKEN_COUNTING_TEST_DATA === "1") { + fs.writeFileSync( + `${__dirname}/test_data/langsmith_js_wrap_openai_${description.replace( + " ", + "_", + )}.json`, + JSON.stringify(requestBodies, null, 2), + ); + } + + callSpy.mockClear(); + }); + }, + ); +}); + +test("chat.completions.stream with finalChatCompletion", async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const stream = openai.chat.completions.stream({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Say 'hello'" }], + }); + + const completion = await stream.finalChatCompletion(); + + expect(completion.choices[0].message.role).toBe("assistant"); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify tracing calls were made + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + + callSpy.mockClear(); +}); + +test("chat.completions.stream with finalMessage", async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const stream = openai.chat.completions.stream({ + model: "gpt-4o-mini", + messages: [{ role: "user", content: "Say 'hello'" }], + }); + + const message = await stream.finalMessage(); + + expect(message.role).toBe("assistant"); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify tracing calls were made + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + + callSpy.mockClear(); +}); + +test("responses.stream with finalResponse", async () => { + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI(new OpenAI(), { + client, + tracingEnabled: true, + }); + + const stream = openai.responses.stream({ + model: "gpt-4.1-nano", + input: [ + { + role: "user", + content: [{ type: "input_text", text: "Say hello" }], + }, + ], + }); + + const response = await stream.finalResponse(); + + expect(response).toBeDefined(); + expect(callSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + + // Verify tracing calls were made + const patchCalls = callSpy.mock.calls.filter( + (call: any) => (call[1] as any).method === "PATCH", + ); + expect(patchCalls.length).toBeGreaterThan(0); + + callSpy.mockClear(); +}); diff --git a/src/lib/tests/wrapped_openai.test.ts b/src/lib/tests/wrapped_openai.test.ts new file mode 100644 index 0000000..1da324a --- /dev/null +++ b/src/lib/tests/wrapped_openai.test.ts @@ -0,0 +1,248 @@ +import { jest } from "@jest/globals"; +import { OpenAI } from "openai"; +import { wrapOpenAI } from "../wrappers/openai.js"; +import { mockClient } from "./utils/mock_client.js"; +import { getAssumedTreeFromCalls } from "./utils/tree.js"; + +test.each([200, 404])( + "responses.create is traced but retrieval with status %i is not", + async (status) => { + const response = { + id: "resp_test", + object: "response", + status: "completed", + model: "gpt-5-nano", + output: [ + { + id: "msg_test", + type: "message", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "4", annotations: [] }], + }, + ], + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const error = { + type: "invalid_request_error", + message: "Response with id 'resp_test' not found.", + }; + const openaiFetch = jest + .fn() + .mockResolvedValueOnce(Response.json(response)) + .mockResolvedValueOnce( + Response.json(status === 200 ? response : { error }, { status }), + ); + const { client, callSpy } = mockClient(); + const openai = wrapOpenAI( + new OpenAI({ + apiKey: "MOCK", + baseURL: "https://openai.example.test/v1", + fetch: openaiFetch, + maxRetries: 0, + }), + { client, tracingEnabled: true }, + ); + const params = { + model: "gpt-5-nano", + input: "What is 2+2?", + store: true, + metadata: { request_key: "request_value" }, + }; + + const created = await openai.responses.create(params); + expect(created).toMatchObject(response); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["ChatOpenAI:0"]); + expect(tree.data["ChatOpenAI:0"]).toMatchObject({ + run_type: "llm", + inputs: params, + extra: { + metadata: { request_key: "request_value", ls_model_name: params.model }, + }, + outputs: { + id: response.id, + usage_metadata: response.usage, + }, + }); + const createCallCount = callSpy.mock.calls.length; + + const retrieved = openai.responses.retrieve(created.id); + if (status === 200) { + await expect(retrieved).resolves.toMatchObject(response); + } else { + await expect(retrieved).rejects.toMatchObject({ status, error }); + } + await client.awaitPendingTraceBatches(); + expect(callSpy.mock.calls.length).toBe(createCallCount); + expect(openaiFetch).toHaveBeenCalledTimes(2); + expect(openaiFetch).toHaveBeenNthCalledWith( + 1, + "https://openai.example.test/v1/responses", + expect.objectContaining({ method: "POST", body: JSON.stringify(params) }), + ); + expect(openaiFetch).toHaveBeenNthCalledWith( + 2, + `https://openai.example.test/v1/responses/${created.id}`, + expect.objectContaining({ method: "GET" }), + ); + }, +); + +// OpenAI shut down its legacy `/v1/completions` models on 2026-09-28; this +// covers wrapping `completions.create` for OpenAI-compatible providers. +function completionsFetch() { + const completion = { + id: "cmpl-test", + object: "text_completion", + created: 1700000000, + model: "gpt-3.5-turbo-instruct", + choices: [ + { + text: " Hi I'm ChatGPT", + index: 0, + logprobs: null, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + const chunks = [" Hi", " I'm", " ChatGPT"].map((text, i) => ({ + ...completion, + choices: [ + { + text, + index: 0, + logprobs: null, + finish_reason: i === 2 ? "stop" : null, + }, + ], + usage: undefined, + })); + const sse = + chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join("") + + "data: [DONE]\n\n"; + return jest.fn().mockImplementation(async (_url, init) => { + const body = JSON.parse(init?.body as string); + return body.stream + ? new Response(sse, { + headers: { "content-type": "text/event-stream" }, + }) + : Response.json(completion); + }); +} + +test("completions", async () => { + const { client, callSpy } = mockClient(); + const openaiFetch = completionsFetch(); + const openaiParams = { + apiKey: "MOCK", + baseURL: "https://openai.example.test/v1", + fetch: openaiFetch, + maxRetries: 0, + }; + const originalClient = new OpenAI(openaiParams); + const patchedClient = wrapOpenAI(new OpenAI(openaiParams), { + client, + tracingEnabled: true, + }); + + const prompt = `Say 'Hi I'm ChatGPT' then stop.`; + + // invoke + const original = await originalClient.completions.create({ + prompt, + temperature: 0, + seed: 42, + model: "gpt-3.5-turbo-instruct", + }); + + const patched = await patchedClient.completions.create({ + prompt, + temperature: 0, + seed: 42, + model: "gpt-3.5-turbo-instruct", + }); + + expect(patched.choices).toEqual(original.choices); + expect(patched.choices[0].text).toBe(" Hi I'm ChatGPT"); + + // stream + const originalStream = await originalClient.completions.create({ + prompt, + temperature: 0, + seed: 42, + model: "gpt-3.5-turbo-instruct", + stream: true, + }); + + const originalChoices: unknown[] = []; + for await (const chunk of originalStream) { + originalChoices.push(chunk.choices); + // @ts-expect-error Should type check streamed output + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _test = chunk.invalidPrompt; + } + expect(originalChoices).toHaveLength(3); + + const patchedStream = await patchedClient.completions.create({ + prompt, + temperature: 0, + seed: 42, + model: "gpt-3.5-turbo-instruct", + stream: true, + }); + + const patchedChoices: unknown[] = []; + for await (const chunk of patchedStream) { + patchedChoices.push(chunk.choices); + // @ts-expect-error Should type check streamed output + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _test = chunk.invalidPrompt; + } + + expect(patchedChoices).toEqual(originalChoices); + + const patchedStream2 = await patchedClient.completions.create( + { + prompt, + temperature: 0, + seed: 42, + model: "gpt-3.5-turbo-instruct", + stream: true, + }, + { + langsmithExtra: { + metadata: { + thing1: "thing2", + }, + }, + }, + ); + + const patchedChoices2: unknown[] = []; + for await (const chunk of patchedStream2) { + patchedChoices2.push(chunk.choices); + // @ts-expect-error Should type check streamed output + const _test = chunk.invalidPrompt; + } + + expect(patchedChoices2).toEqual(originalChoices); + expect(openaiFetch).toHaveBeenCalledTimes(5); + for (const [url, init] of openaiFetch.mock.calls) { + expect(url).toBe("https://openai.example.test/v1/completions"); + expect(init).toMatchObject({ method: "POST" }); + } + + await client.awaitPendingTraceBatches(); + const tree = await getAssumedTreeFromCalls(callSpy.mock.calls, client); + expect(tree.nodes).toEqual(["OpenAI:0", "OpenAI:1", "OpenAI:2"]); + expect(tree.data["OpenAI:0"]).toMatchObject({ + run_type: "llm", + inputs: { prompt, model: "gpt-3.5-turbo-instruct" }, + outputs: { choices: original.choices }, + }); + expect(tree.data["OpenAI:2"]).toMatchObject({ + extra: { metadata: { thing1: "thing2" } }, + }); +}); diff --git a/src/lib/traceable.ts b/src/lib/traceable.ts new file mode 100644 index 0000000..ee58985 --- /dev/null +++ b/src/lib/traceable.ts @@ -0,0 +1,1300 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +import { + RunTree, + RunTreeConfig, + RunnableConfigLike, + isRunTree, + isRunnableConfigLike, +} from "./run_trees.js"; +import { + Attachments, + InvocationParamsSchema, + KVMap, + ExtractedUsageMetadata, +} from "./schemas.js"; +import { allowUnprocessedPayloads, isEnvTracingEnabled } from "./env.js"; +import { + ROOT, + AsyncLocalStorageProviderSingleton, + getCurrentRunTree, +} from "./singletons/traceable.js"; +import { + _LC_CHILD_RUN_END_PROMISES_KEY, + _LC_CONTEXT_VARIABLES_KEY, + _PROCESSING_FAILED_KEY, + _processingFailed, +} from "./singletons/constants.js"; +import type { + TraceableFunction, + ContextPlaceholder, +} from "./singletons/types.js"; +import { + isKVMap, + isReadableStream, + isAsyncIterable, + isIteratorLike, + isThenable, + isGenerator, + isPromiseMethod, +} from "./utils/asserts.js"; +import { __version__ } from "./index.js"; +import { getOTELTrace, getOTELContext } from "./singletons/otel.js"; +import { getUuidFromOtelSpanId } from "./experimental/otel/utils.js"; +import { OTELTracer } from "./experimental/otel/types.js"; +import { + LANGSMITH_REFERENCE_EXAMPLE_ID, + LANGSMITH_SESSION_NAME, + LANGSMITH_TRACEABLE, +} from "./experimental/otel/constants.js"; + +AsyncLocalStorageProviderSingleton.initializeGlobalInstance( + new AsyncLocalStorage(), +); + +/** + * Create OpenTelemetry context manager from RunTree if OTEL is enabled. + */ +function maybeCreateOtelContext( + runTree?: RunTree, + projectName?: string, + tracer?: OTELTracer, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): ((fn: (...args: any[]) => T) => T) | undefined { + if (!runTree || runTree.client.tracingMode !== "otel") { + return; + } + + const otel_trace = getOTELTrace(); + + try { + const activeTraceId = otel_trace.getActiveSpan()?.spanContext()?.traceId; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (fn: (...args: any[]) => T) => { + const resolvedTracer = + tracer ?? otel_trace.getTracer("langsmith", __version__); + const attributes: KVMap = { + [LANGSMITH_TRACEABLE]: "true", + }; + if (runTree.reference_example_id) { + attributes[LANGSMITH_REFERENCE_EXAMPLE_ID] = + runTree.reference_example_id; + } + if (projectName !== undefined) { + attributes[LANGSMITH_SESSION_NAME] = projectName; + } + const forceOTELRoot = runTree.extra?.ls_otel_root === true; + return resolvedTracer.startActiveSpan( + runTree.name, + { + attributes, + root: forceOTELRoot, + }, + () => { + if (activeTraceId === undefined || forceOTELRoot) { + const otelSpanId = otel_trace.getActiveSpan()?.spanContext() + ?.spanId; + if (otelSpanId) { + const langsmithTraceId = getUuidFromOtelSpanId(otelSpanId); + // Must refetch from our primary async local storage + const currentRunTree = getCurrentRunTree(); + if (currentRunTree) { + // This is only for root runs to ensure that trace id + // and the root run id are returned correctly. + // This is important for things like leaving feedback on + // target function runs during evaluation. + currentRunTree.id = langsmithTraceId; + currentRunTree.trace_id = langsmithTraceId; + } + } + } + return fn(); + }, + ); + }; + } catch { + // Silent failure if OTEL setup is incomplete + return; + } +} + +const runInputsToMap = (rawInputs: unknown[]) => { + const firstInput = rawInputs[0]; + let inputs: KVMap; + + if (firstInput === null) { + inputs = { inputs: null }; + } else if (firstInput === undefined) { + inputs = {}; + } else if (rawInputs.length > 1) { + inputs = { args: rawInputs }; + } else if (isKVMap(firstInput)) { + inputs = firstInput; + } else { + inputs = { input: firstInput }; + } + return inputs; +}; + +const handleRunInputs = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: any, + processInputs: ( + inputs: Readonly>, + ) => KVMap | Promise, +): KVMap | Promise => { + // Fail closed: trace the run, but with a marker instead of raw inputs. + const dropped = (e: unknown) => { + if (allowUnprocessedPayloads()) { + console.warn( + "Error occurred during processInputs. Tracing unprocessed inputs " + + "because LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS is set:", + e, + ); + return inputs; + } + console.warn("Error occurred during processInputs. Dropping inputs:", e); + return { + [_PROCESSING_FAILED_KEY]: _processingFailed("inputs", "processInputs"), + }; + }; + try { + const processed = processInputs(inputs); + return isThenable(processed) + ? Promise.resolve(processed).catch(dropped) + : processed; + } catch (e) { + return dropped(e); + } +}; + +const _extractUsage = (runData: { + runTree: RunTree; + outputs?: KVMap; +}): ExtractedUsageMetadata | undefined => { + const usageMetadataFromMetadata = (runData.runTree.extra.metadata ?? {}) + .usage_metadata; + return runData.outputs?.usage_metadata ?? usageMetadataFromMetadata; +}; + +async function handleEnd(params: { + runTree?: RunTree; + on_end: (runTree?: RunTree) => void; + postRunPromise?: Promise; + deferredInputs?: boolean; +}) { + const { runTree, on_end, postRunPromise, deferredInputs } = params; + const onEnd = on_end; + if (onEnd) { + onEnd(runTree); + } + await postRunPromise; + if (deferredInputs) { + await runTree?.postRun(); + } else { + await runTree?.patchRun({ + excludeInputs: true, + }); + } +} + +const _populateUsageMetadataAndOutputs = ( + processedOutputs: KVMap, + runTree?: RunTree, +) => { + if (runTree !== undefined) { + let usageMetadata: ExtractedUsageMetadata | undefined; + try { + usageMetadata = _extractUsage({ runTree, outputs: processedOutputs }); + } catch (e) { + console.error("Error occurred while extracting usage metadata:", e); + } + if (usageMetadata !== undefined) { + runTree.extra.metadata = { + ...runTree.extra.metadata, + usage_metadata: usageMetadata, + }; + processedOutputs.usage_metadata = usageMetadata; + } + // Set outputs on run tree as soon as available + runTree.outputs = processedOutputs; + } +}; + +function isAsyncFn( + fn: unknown, +): fn is (...args: unknown[]) => Promise { + return ( + fn != null && + typeof fn === "function" && + fn.constructor.name === "AsyncFunction" + ); +} + +// Note: This mutates the run tree +async function handleRunOutputs(params: { + runTree?: RunTree; + rawOutputs: unknown; + processOutputsFn: ( + outputs: Readonly>, + ) => KVMap | Promise; + on_end: (runTree?: RunTree) => void; + postRunPromise?: Promise; + deferredInputs?: boolean; + skipChildPromiseDelay?: boolean; +}): Promise { + const { + runTree, + rawOutputs, + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + skipChildPromiseDelay, + } = params; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let outputs: any; + + if (isKVMap(rawOutputs)) { + outputs = { ...rawOutputs }; + } else { + outputs = { outputs: rawOutputs }; + } + // `outputs` is overwritten by the redactor call below; keep the raw value. + const unprocessed: KVMap = outputs; + + const childRunEndPromises = + !skipChildPromiseDelay && + isRunTree(runTree) && + _LC_CHILD_RUN_END_PROMISES_KEY in runTree && + Array.isArray(runTree[_LC_CHILD_RUN_END_PROMISES_KEY]) + ? Promise.all(runTree[_LC_CHILD_RUN_END_PROMISES_KEY] ?? []) + : Promise.resolve(); + + // Fail closed, as for inputs: a marker rather than the raw value. + const droppedOutputs = (e: unknown): KVMap => { + if (allowUnprocessedPayloads()) { + console.error( + "Error occurred during processOutputs. Tracing unprocessed outputs " + + "because LANGSMITH_ALLOW_UNPROCESSED_PAYLOADS is set:", + e, + ); + return unprocessed; + } + console.error("Error occurred during processOutputs. Dropping outputs:", e); + return { + [_PROCESSING_FAILED_KEY]: _processingFailed("outputs", "processOutputs"), + }; + }; + const endOutputsDropped = async (e: unknown) => { + const finalOutputs = droppedOutputs(e); + try { + await childRunEndPromises; + await runTree?.end(finalOutputs); + } catch (endErr) { + console.error("Error occurred during runTree?.end.", endErr); + } + }; + + try { + outputs = processOutputsFn(outputs); + // TODO: Investigate making this behavior for all returned promises + // on next minor bump. + if (isAsyncFn(processOutputsFn)) { + void outputs + .then(async (processedOutputs: KVMap) => { + _populateUsageMetadataAndOutputs(processedOutputs, runTree); + await childRunEndPromises; + await runTree?.end(processedOutputs); + }) + .catch(endOutputsDropped) + .finally(async () => { + try { + await handleEnd({ + runTree, + postRunPromise, + on_end, + deferredInputs, + }); + } catch (e) { + console.error("Error occurred during handleEnd.", e); + } + }); + return; + } + } catch (e) { + outputs = droppedOutputs(e); + } + _populateUsageMetadataAndOutputs(outputs, runTree); + void childRunEndPromises + .then(async () => { + try { + await runTree?.end(outputs); + await handleEnd({ runTree, postRunPromise, on_end, deferredInputs }); + } catch (e) { + console.error(e); + } + }) + .catch((e) => { + console.error( + "Error occurred during childRunEndPromises.then. This should never happen.", + e, + ); + }); + return; +} + +const handleRunAttachments = ( + rawInputs: unknown[], + extractAttachments?: ( + ...args: unknown[] + ) => [Attachments | undefined, unknown[]], +): [Attachments | undefined, unknown[]] => { + if (!extractAttachments) { + return [undefined, rawInputs]; + } + + try { + const [attachments, remainingArgs] = extractAttachments(...rawInputs); + return [attachments, remainingArgs]; + } catch (e) { + console.error("Error occurred during extractAttachments:", e); + return [undefined, rawInputs]; + } +}; + +const getTracingRunTree = ( + runTree: RunTree, + inputs: Args, + getInvocationParams: + | ((...args: Args) => InvocationParamsSchema | undefined) + | undefined, + processInputs: ( + inputs: Readonly>, + ) => KVMap | Promise, + extractAttachments: + | ((...args: Args) => [Attachments | undefined, KVMap]) + | undefined, +): RunTree | ContextPlaceholder => { + if (!isEnvTracingEnabled(runTree.tracingEnabled)) { + return { tracingEnabled: runTree.tracingEnabled }; + } + + const [attached, args] = handleRunAttachments( + inputs, + extractAttachments as + | ((...args: unknown[]) => [Attachments | undefined, unknown[]]) + | undefined, + ); + runTree.attachments = attached; + const processedInputs = handleRunInputs(args, processInputs); + // Off the value, not the fn shape: non-async redactors return promises too. + // Client.prepareRunCreateOrUpdateInputs also flattens it; don't rely on that. + if (isThenable(processedInputs)) { + runTree._awaitInputsOnPost = true; + } + runTree.inputs = processedInputs; + + const invocationParams = getInvocationParams?.(...inputs); + if (invocationParams != null) { + runTree.extra ??= {}; + runTree.extra.metadata = { + ...invocationParams, + ...runTree.extra.metadata, + }; + } + + return runTree; +}; + +// idea: store the state of the promise outside +// but only when the promise is "consumed" +const getSerializablePromise = (arg: Promise) => { + const proxyState: { + current: ["resolve", unknown] | ["reject", unknown] | undefined; + } = { current: undefined }; + + const promiseProxy = new Proxy(arg, { + get(target, prop, receiver) { + if (prop === "then") { + const boundThen = arg[prop].bind(arg); + return ( + resolve: (value: unknown) => unknown, + reject: (error: unknown) => unknown = (x) => { + throw x; + }, + ) => { + return boundThen( + (value: unknown) => { + proxyState.current = ["resolve", value]; + return resolve(value); + }, + (error: unknown) => { + proxyState.current = ["reject", error]; + return reject(error); + }, + ); + }; + } + + if (prop === "catch") { + const boundCatch = arg[prop].bind(arg); + return (reject: (error: unknown) => unknown) => { + return boundCatch((error: unknown) => { + proxyState.current = ["reject", error]; + return reject(error); + }); + }; + } + + if (prop === "toJSON") { + return () => { + if (!proxyState.current) return undefined; + const [type, value] = proxyState.current ?? []; + if (type === "resolve") return value; + return { error: value }; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); + + return promiseProxy as Promise & { toJSON: () => unknown }; +}; + +const convertSerializableArg = ( + arg: unknown, + options?: { depth?: number; maxDepth?: number }, +): { converted: unknown; deferredInputs: boolean } => { + if (isReadableStream(arg)) { + const proxyState: unknown[] = []; + const transform = new TransformStream({ + start: () => void 0, + transform: (chunk, controller) => { + proxyState.push(chunk); + controller.enqueue(chunk); + }, + flush: () => void 0, + }); + + const pipeThrough = arg.pipeThrough(transform); + Object.assign(pipeThrough, { toJSON: () => proxyState }); + return { converted: pipeThrough, deferredInputs: true }; + } + + if (isAsyncIterable(arg)) { + const proxyState: { + current: (Promise> & { + toJSON: () => unknown; + })[]; + } = { current: [] }; + + const converted = new Proxy(arg, { + get(target, prop, receiver) { + if (prop === Symbol.asyncIterator) { + return () => { + const boundIterator = arg[Symbol.asyncIterator].bind(arg); + const iterator = boundIterator(); + + return new Proxy(iterator, { + get(target, prop, receiver) { + if (prop === "next" || prop === "return" || prop === "throw") { + const bound = iterator.next.bind(iterator); + + return ( + ...args: Parameters< + Exclude< + AsyncIterator["next" | "return" | "throw"], + undefined + > + > + ) => { + const wrapped = getSerializablePromise( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bound(...(args as any)), + ); + proxyState.current.push( + wrapped as Promise> & { + toJSON: () => IteratorResult; + }, + ); + return wrapped; + }; + } + + if (prop === "return" || prop === "throw") { + return iterator.next.bind(iterator); + } + + return Reflect.get(target, prop, receiver); + }, + }); + }; + } + + if (prop === "toJSON") { + return () => { + const onlyNexts = proxyState.current; + const serialized = onlyNexts.map( + (next) => next.toJSON() as IteratorResult | undefined, + ); + + const chunks = serialized.reduce((memo, next) => { + if (next?.value) memo.push(next.value); + return memo; + }, []); + + return chunks; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); + return { converted, deferredInputs: true }; + } + + if (!Array.isArray(arg) && isIteratorLike(arg)) { + const proxyState: Array> = []; + + const converted = new Proxy(arg, { + get(target, prop, receiver) { + if (prop === "next" || prop === "return" || prop === "throw") { + const bound = arg[prop]?.bind(arg); + return ( + ...args: Parameters< + Exclude["next" | "return" | "throw"], undefined> + > + ) => { + const next = bound?.(...args); + if (next != null) proxyState.push(next); + return next; + }; + } + + if (prop === "toJSON") { + return () => { + const chunks = proxyState.reduce((memo, next) => { + if (next.value) memo.push(next.value); + return memo; + }, []); + + return chunks; + }; + } + + return Reflect.get(target, prop, receiver); + }, + }); + return { converted, deferredInputs: true }; + } + + if (isThenable(arg)) { + return { + converted: getSerializablePromise(arg), + deferredInputs: true, + }; + } + + const maxDepth = options?.maxDepth ?? 0; + const currentDepth = options?.depth ?? 0; + + if (currentDepth < maxDepth) { + if (Array.isArray(arg)) { + const converted = [] as unknown[]; + let deferredInputs = false; + + for (let i = 0; i < arg.length; i++) { + const res = convertSerializableArg(arg[i], { + depth: currentDepth + 1, + maxDepth, + }); + converted.push(res.converted); + deferredInputs = deferredInputs || res.deferredInputs; + } + return { converted, deferredInputs }; + } + + if (typeof arg === "object" && arg != null) { + const converted = {} as Record; + let deferredInputs = false; + + for (const key in arg) { + if (Object.prototype.hasOwnProperty.call(arg, key)) { + const res = convertSerializableArg(arg[key as keyof typeof arg], { + ...options, + depth: currentDepth + 1, + }); + converted[key] = res.converted; + deferredInputs = deferredInputs || res.deferredInputs; + } + } + + return { converted, deferredInputs }; + } + } + + return { converted: arg, deferredInputs: false }; +}; + +export type ProcessInputs = Args extends [] + ? Record + : Args extends [infer Input] + ? Input extends KVMap + ? Input extends Iterable | AsyncIterable + ? { input: Array } + : Input + : { input: Input } + : { args: Args }; + +export type ProcessOutputs = ReturnValue extends KVMap + ? ReturnValue extends Iterable | AsyncIterable + ? { outputs: Array } + : ReturnValue + : { outputs: ReturnValue }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TraceableConfig any> = Partial< + Omit +> & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + aggregator?: (args: any[]) => any; + argsConfigPath?: [number] | [number, string]; + tracer?: OTELTracer; + on_start?: (runTree: RunTree | undefined) => void; + __finalTracedIteratorKey?: string; + __deferredSerializableArgOptions?: { + depth?: number; + maxDepth?: number; + }; + + /** + * Extract attachments from args and return remaining args. + * @param args Arguments of the traced function + * @returns Tuple of [Attachments, remaining args] + */ + extractAttachments?: ( + ...args: Parameters + ) => [Attachments | undefined, KVMap]; + + /** + * Extract invocation parameters from the arguments of the traced function. + * This is useful for LangSmith to properly track common metadata like + * provider, model name and temperature. + * + * @param args Arguments of the traced function + * @returns Key-value map of the invocation parameters, which will be merged with the existing metadata + */ + getInvocationParams?: ( + ...args: Parameters + ) => InvocationParamsSchema | undefined; + + /** + * Apply transformations to the inputs before logging. + * This function should NOT mutate the inputs. + * `processInputs` is not inherited by nested traceable functions. + * + * The input to this function is determined as follows based on the + * arguments passed to the wrapped function: + * - If called with one argument that is an object, it will be the unchanged argument + * - If called with one argument that is not an object, it will be `{ input: arg }` + * - If called with multiple arguments, it will be `{ args: [...arguments] }` + * - If called with no arguments, it will be an empty object `{}` + * + * @param inputs Key-value map of the function inputs. + * @returns Transformed key-value map + */ + processInputs?: ( + inputs: Readonly>>, + ) => KVMap | Promise; + + /** + * Apply transformations to the outputs before logging. + * This function should NOT mutate the outputs. + * `processOutputs` is not inherited by nested traceable functions. + * + * The input to this function is determined as follows based on the + * return value of the wrapped function: + * - If the return value is an object, it will be the unchanged return value + * - If the return value is not an object, it will wrapped as `{ outputs: returnValue }` + * + * @param outputs Key-value map of the function outputs + * @returns Transformed key-value map + */ + processOutputs?: ( + outputs: Readonly>>>, + ) => KVMap | Promise; +}; + +/** + * Higher-order function that takes function as input and returns a + * "TraceableFunction" - a wrapped version of the input that + * automatically handles tracing. If the returned traceable function calls any + * traceable functions, those are automatically traced as well. + * + * The returned TraceableFunction can accept a run tree or run tree config as + * its first argument. If omitted, it will default to the caller's run tree, + * or will be treated as a root run. + * + * @param wrappedFunc Targeted function to be traced + * @param config Additional metadata such as name, tags or providing + * a custom LangSmith client instance + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function traceable any>( + wrappedFunc: Func, + config?: TraceableConfig, +) { + type Inputs = Parameters; + const { + aggregator, + argsConfigPath, + __finalTracedIteratorKey, + processInputs, + processOutputs, + extractAttachments, + on_start, + ...runTreeConfig + } = config ?? {}; + + const processInputsFn = processInputs ?? ((x) => x); + const processOutputsFn = processOutputs ?? ((x) => x); + const extractAttachmentsFn = + extractAttachments ?? ((...x) => [undefined, runInputsToMap(x)]); + + const traceableFunc = ( + ...args: Inputs | [RunTree, ...Inputs] | [RunnableConfigLike, ...Inputs] + ) => { + let ensuredConfig: RunTreeConfig; + try { + let runtimeConfig: Partial | undefined; + if (argsConfigPath) { + const [index, path] = argsConfigPath; + if (index === args.length - 1 && !path) { + runtimeConfig = args.pop() as Partial; + } else if ( + index <= args.length && + typeof args[index] === "object" && + args[index] !== null + ) { + if (path) { + const { [path]: extracted, ...rest } = args[index]; + runtimeConfig = extracted as Partial; + args[index] = rest; + } else { + runtimeConfig = args[index] as Partial; + args.splice(index, 1); + } + } + } + + ensuredConfig = { + name: wrappedFunc.name || "", + ...runTreeConfig, + ...runtimeConfig, + tags: [ + ...new Set([ + ...(runTreeConfig?.tags ?? []), + ...(runtimeConfig?.tags ?? []), + ]), + ], + metadata: { + ...runTreeConfig?.metadata, + ...runtimeConfig?.metadata, + }, + }; + } catch (err) { + console.warn( + `Failed to extract runtime config from args for ${ + runTreeConfig?.name ?? wrappedFunc.name + }`, + err, + ); + ensuredConfig = { + name: wrappedFunc.name || "", + ...runTreeConfig, + }; + } + + let runEndedPromiseResolver: () => void; + const runEndedPromise = new Promise((resolve) => { + runEndedPromiseResolver = resolve; + }); + const on_end = (runTree?: RunTree) => { + if (config?.on_end) { + if (!runTree) { + console.warn("Can not call 'on_end' if currentRunTree is undefined"); + } else { + config.on_end(runTree); + } + } + runEndedPromiseResolver(); + }; + const asyncLocalStorage = AsyncLocalStorageProviderSingleton.getInstance(); + + const processedArgs = args as Inputs; + let deferredInputs = false; + for (let i = 0; i < processedArgs.length; i++) { + const { converted, deferredInputs: argDefersInput } = + convertSerializableArg( + processedArgs[i], + config?.__deferredSerializableArgOptions, + ); + processedArgs[i] = converted; + deferredInputs = deferredInputs || argDefersInput; + } + + const [currentContext, rawInputs] = ((): [ + RunTree | ContextPlaceholder, + Inputs, + ] => { + const [firstArg, ...restArgs] = processedArgs; + + // used for handoff between LangChain.JS and traceable functions + if (isRunnableConfigLike(firstArg)) { + return [ + getTracingRunTree( + RunTree.fromRunnableConfig(firstArg, ensuredConfig), + restArgs as Inputs, + config?.getInvocationParams, + processInputsFn, + extractAttachmentsFn, + ), + restArgs as Inputs, + ]; + } + + // deprecated: legacy CallbackManagerRunTree used in runOnDataset + // override ALS and do not pass-through the run tree + if ( + isRunTree(firstArg) && + "callbackManager" in firstArg && + firstArg.callbackManager != null + ) { + return [firstArg, restArgs as Inputs]; + } + + // when ALS is unreliable, users can manually + // pass in the run tree + if (firstArg === ROOT || isRunTree(firstArg)) { + const currentRunTree = getTracingRunTree( + firstArg === ROOT + ? new RunTree(ensuredConfig) + : firstArg.createChild(ensuredConfig), + restArgs as Inputs, + config?.getInvocationParams, + processInputsFn, + extractAttachmentsFn, + ); + + return [currentRunTree, [currentRunTree, ...restArgs] as Inputs]; + } + + // Node.JS uses AsyncLocalStorage (ALS) and AsyncResource + // to allow storing context + const prevRunFromStore = asyncLocalStorage.getStore(); + let lc_contextVars: unknown; + // If a context var is set by LangChain outside of a traceable, + // it will be an object with a single property and we should copy + // context vars over into the new run tree. + if ( + prevRunFromStore !== undefined && + _LC_CONTEXT_VARIABLES_KEY in prevRunFromStore + ) { + lc_contextVars = prevRunFromStore[_LC_CONTEXT_VARIABLES_KEY]; + } + if (isRunTree(prevRunFromStore)) { + if ( + _LC_CHILD_RUN_END_PROMISES_KEY in prevRunFromStore && + Array.isArray(prevRunFromStore[_LC_CHILD_RUN_END_PROMISES_KEY]) + ) { + prevRunFromStore[_LC_CHILD_RUN_END_PROMISES_KEY].push( + runEndedPromise, + ); + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (prevRunFromStore as any)[_LC_CHILD_RUN_END_PROMISES_KEY] = [ + runEndedPromise, + ]; + } + const currentRunTree = getTracingRunTree( + prevRunFromStore.createChild(ensuredConfig), + processedArgs, + config?.getInvocationParams, + processInputsFn, + extractAttachmentsFn, + ); + if (lc_contextVars) { + ((currentRunTree ?? {}) as Record)[ + _LC_CONTEXT_VARIABLES_KEY + ] = lc_contextVars; + } + return [currentRunTree, processedArgs as Inputs]; + } + + // If the parent context explicitly disabled tracing and the child + // didn't override it, propagate the tracingEnabled setting so that + // child runs inside a traceable({ tracingEnabled: false }) wrapper + // also have tracing disabled. + const childConfig = + ensuredConfig.tracingEnabled === undefined && + prevRunFromStore?.tracingEnabled !== undefined + ? { + ...ensuredConfig, + tracingEnabled: prevRunFromStore.tracingEnabled, + } + : ensuredConfig; + + const currentRunTree = getTracingRunTree( + new RunTree(childConfig), + processedArgs, + config?.getInvocationParams, + processInputsFn, + extractAttachmentsFn, + ); + if (lc_contextVars) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((currentRunTree ?? {}) as any)[_LC_CONTEXT_VARIABLES_KEY] = + lc_contextVars; + } + return [currentRunTree, processedArgs as Inputs]; + })(); + + const currentRunTree = isRunTree(currentContext) + ? currentContext + : undefined; + + on_start?.(currentRunTree); + + const otelContextManager = maybeCreateOtelContext( + currentRunTree, + config?.project_name, + config?.tracer, + ); + const otel_context = getOTELContext(); + + const runWithContext = () => { + const postRunPromise = !deferredInputs + ? currentRunTree?.postRun() + : Promise.resolve(); + + async function handleChunks(chunks: unknown[]) { + if (aggregator !== undefined) { + try { + return await aggregator(chunks); + } catch (e) { + console.error(`[ERROR]: LangSmith aggregation failed: `, e); + } + } + + return chunks; + } + + function tapReadableStreamForTracing( + stream: ReadableStream, + snapshot: ReturnType | undefined, + ) { + const reader = stream.getReader(); + let finished = false; + const chunks: unknown[] = []; + const capturedOtelContext = otel_context.active(); + + const tappedStream = new ReadableStream({ + async start(controller) { + // eslint-disable-next-line no-constant-condition + while (true) { + const result = await (snapshot + ? snapshot(() => + otel_context.with(capturedOtelContext, () => reader.read()), + ) + : otel_context.with(capturedOtelContext, () => reader.read())); + if (result.done) { + finished = true; + await handleRunOutputs({ + runTree: currentRunTree, + rawOutputs: await handleChunks(chunks), + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + }); + controller.close(); + break; + } + chunks.push(result.value); + // Add new_token event for streaming LLM runs + if (currentRunTree && currentRunTree.run_type === "llm") { + currentRunTree.addEvent({ + name: "new_token", + kwargs: { token: result.value }, + }); + } + controller.enqueue(result.value); + } + }, + async cancel(reason) { + if (!finished) await currentRunTree?.end(undefined, "Cancelled"); + await handleRunOutputs({ + runTree: currentRunTree, + rawOutputs: await handleChunks(chunks), + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + }); + return reader.cancel(reason); + }, + }); + + return tappedStream; + } + + async function* wrapAsyncIteratorForTracing( + iterator: AsyncIterator, + snapshot: ReturnType | undefined, + ) { + let finished = false; + let hasError = false; + const chunks: unknown[] = []; + const capturedOtelContext = otel_context.active(); + try { + while (true) { + const { value, done } = await (snapshot + ? snapshot(() => + otel_context.with(capturedOtelContext, () => iterator.next()), + ) + : otel_context.with(capturedOtelContext, () => iterator.next())); + if (done) { + finished = true; + break; + } + chunks.push(value); + // Add new_token event for streaming LLM runs + if (currentRunTree && currentRunTree.run_type === "llm") { + currentRunTree.addEvent({ + name: "new_token", + kwargs: { token: value }, + }); + } + yield value; + } + } catch (e) { + hasError = true; + await currentRunTree?.end(undefined, String(e)); + throw e; + } finally { + if (!finished) { + // Call return() on the original iterator to trigger cleanup + if (iterator.return) { + await iterator.return(undefined); + } + await currentRunTree?.end(undefined, "Cancelled"); + } + await handleRunOutputs({ + runTree: currentRunTree, + rawOutputs: await handleChunks(chunks), + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + skipChildPromiseDelay: hasError || !finished, + }); + } + } + + function wrapAsyncGeneratorForTracing( + iterable: AsyncIterable, + snapshot: ReturnType | undefined, + ) { + if (isReadableStream(iterable)) { + return tapReadableStreamForTracing(iterable, snapshot); + } + const iterator = iterable[Symbol.asyncIterator](); + const wrappedIterator = wrapAsyncIteratorForTracing(iterator, snapshot); + iterable[Symbol.asyncIterator] = () => wrappedIterator; + return iterable; + } + + function gatherAll(iterator: Iterator) { + const chunks: IteratorResult[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + const next = iterator.next(); + chunks.push(next); + if (next.done) break; + } + + return chunks; + } + + let returnValue: unknown; + try { + returnValue = wrappedFunc(...rawInputs); + } catch (err: unknown) { + returnValue = Promise.reject(err); + } + + if (isAsyncIterable(returnValue)) { + const snapshot = AsyncLocalStorage.snapshot(); + return wrapAsyncGeneratorForTracing(returnValue, snapshot); + } + + if ( + !Array.isArray(returnValue) && + typeof returnValue === "object" && + returnValue != null && + __finalTracedIteratorKey !== undefined && + isAsyncIterable( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (returnValue as Record)[__finalTracedIteratorKey], + ) + ) { + const snapshot = AsyncLocalStorage.snapshot(); + return { + ...returnValue, + [__finalTracedIteratorKey]: wrapAsyncGeneratorForTracing( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (returnValue as Record)[__finalTracedIteratorKey], + snapshot, + ), + }; + } + + const tracedPromise = new Promise((resolve, reject) => { + Promise.resolve(returnValue) + .then( + async (rawOutput) => { + if (isAsyncIterable(rawOutput)) { + const snapshot = AsyncLocalStorage.snapshot(); + return resolve( + wrapAsyncGeneratorForTracing(rawOutput, snapshot), + ); + } + + if ( + !Array.isArray(rawOutput) && + typeof rawOutput === "object" && + rawOutput != null && + __finalTracedIteratorKey !== undefined && + isAsyncIterable( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rawOutput as Record)[__finalTracedIteratorKey], + ) + ) { + const snapshot = AsyncLocalStorage.snapshot(); + return { + ...rawOutput, + [__finalTracedIteratorKey]: wrapAsyncGeneratorForTracing( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (rawOutput as Record)[ + __finalTracedIteratorKey + ], + snapshot, + ), + }; + } + + if (isGenerator(wrappedFunc) && isIteratorLike(rawOutput)) { + const chunks = gatherAll(rawOutput); + + try { + await handleRunOutputs({ + runTree: currentRunTree, + rawOutputs: await handleChunks( + chunks.reduce((memo, { value, done }) => { + if (!done || typeof value !== "undefined") { + memo.push(value); + } + + return memo; + }, []), + ), + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + }); + } catch (e) { + console.error( + "[LANGSMITH]: Error occurred while handling run outputs:", + e, + ); + } + + return (function* () { + for (const ret of chunks) { + if (ret.done) return ret.value; + yield ret.value; + } + })(); + } + + try { + await handleRunOutputs({ + runTree: currentRunTree, + rawOutputs: rawOutput, + processOutputsFn, + on_end, + postRunPromise, + deferredInputs, + }); + } finally { + // eslint-disable-next-line no-unsafe-finally + return rawOutput; + } + }, + async (error: unknown) => { + // Don't wait for child runs on error - fail fast + await currentRunTree?.end(undefined, String(error)); + await handleEnd({ + runTree: currentRunTree, + postRunPromise, + on_end, + deferredInputs, + }); + throw error; + }, + ) + .then(resolve, reject); + }); + + if (typeof returnValue !== "object" || returnValue === null) { + return tracedPromise; + } + + return new Proxy(returnValue, { + get(target, prop, receiver) { + if (isPromiseMethod(prop)) { + return tracedPromise[prop].bind(tracedPromise); + } + return Reflect.get(target, prop, receiver); + }, + }); + }; + + // Wrap with OTEL context if available, similar to Python's implementation + if (otelContextManager) { + return asyncLocalStorage.run(currentContext, () => + otelContextManager(runWithContext), + ); + } else { + return asyncLocalStorage.run(currentContext, runWithContext); + } + }; + + Object.defineProperty(traceableFunc, "langsmith:traceable", { + value: runTreeConfig, + }); + + return traceableFunc as TraceableFunction; +} + +export { + getCurrentRunTree, + isTraceableFunction, + withRunTree, + ROOT, +} from "./singletons/traceable.js"; + +export type { RunTreeLike, TraceableFunction } from "./singletons/types.js"; diff --git a/src/lib/utils/_git.ts b/src/lib/utils/_git.ts new file mode 100644 index 0000000..4e5e075 --- /dev/null +++ b/src/lib/utils/_git.ts @@ -0,0 +1,135 @@ +interface GitInfo { + remoteUrl?: string | null; + commit?: string | null; + branch?: string | null; + authorName?: string | null; + authorEmail?: string | null; + commitMessage?: string | null; + commitTime?: string | null; + dirty?: boolean | null; + tags?: string | null; +} + +async function importChildProcess() { + const { exec } = await import("child_process"); + return { exec }; +} + +const execGit = ( + command: string[], + exec: (...args: any[]) => any, +): Promise => { + return new Promise((resolve) => { + exec(`git ${command.join(" ")}`, (error: any, stdout: any) => { + if (error) { + resolve(null); + } else { + resolve(stdout.trim()); + } + }); + }); +}; + +function sanitizeRemoteUrl( + remoteUrl?: string | null, +): string | null | undefined { + if (remoteUrl == null) { + return remoteUrl; + } + const schemeSeparator = remoteUrl.indexOf("://"); + if (schemeSeparator === -1) { + return remoteUrl; + } + const scheme = remoteUrl.slice(0, schemeSeparator).toLowerCase(); + if (scheme !== "http" && scheme !== "https") { + return remoteUrl; + } + const authorityStart = schemeSeparator + "://".length; + let authorityEnd = remoteUrl.length; + for (const delimiter of ["/", "?", "#"]) { + const delimiterIndex = remoteUrl.indexOf(delimiter, authorityStart); + if (delimiterIndex !== -1) { + authorityEnd = Math.min(authorityEnd, delimiterIndex); + } + } + const authority = remoteUrl.slice(authorityStart, authorityEnd); + const userinfoEnd = authority.lastIndexOf("@"); + if (userinfoEnd === -1) { + return remoteUrl; + } + return `${remoteUrl.slice(0, authorityStart)}${authority.slice( + userinfoEnd + 1, + )}${remoteUrl.slice(authorityEnd)}`; +} + +export const getGitInfo = async ( + remote = "origin", +): Promise => { + let exec: (...args: any[]) => any; + try { + const execImport = await importChildProcess(); + exec = execImport.exec; + } catch (_e) { + // no-op + return null; + } + + const isInsideWorkTree = await execGit( + ["rev-parse", "--is-inside-work-tree"], + exec, + ); + if (!isInsideWorkTree) { + return null; + } + + const [ + remoteUrl, + commit, + commitTime, + branch, + tags, + dirty, + authorName, + authorEmail, + ] = await Promise.all([ + execGit(["remote", "get-url", remote], exec), + execGit(["rev-parse", "HEAD"], exec), + execGit(["log", "-1", "--format=%ct"], exec), + execGit(["rev-parse", "--abbrev-ref", "HEAD"], exec), + execGit( + ["describe", "--tags", "--exact-match", "--always", "--dirty"], + exec, + ), + execGit(["status", "--porcelain"], exec).then((output) => output !== ""), + execGit(["log", "-1", "--format=%an"], exec), + execGit(["log", "-1", "--format=%ae"], exec), + ]); + + return { + remoteUrl: sanitizeRemoteUrl(remoteUrl), + commit, + commitTime, + branch, + tags, + dirty, + authorName, + authorEmail, + }; +}; + +export const getDefaultRevisionId = async (): Promise => { + let exec: (...args: any[]) => any; + try { + const execImport = await importChildProcess(); + exec = execImport.exec; + } catch (_e) { + // no-op + return null; + } + + const commit = await execGit(["rev-parse", "HEAD"], exec); + if (!commit) { + return null; + } + return commit; +}; diff --git a/src/lib/utils/_uuid.ts b/src/lib/utils/_uuid.ts new file mode 100644 index 0000000..2f47b03 --- /dev/null +++ b/src/lib/utils/_uuid.ts @@ -0,0 +1,195 @@ +// Relaxed UUID validation regex (allows any valid UUID format including nil UUIDs) +const UUID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +import { v7 as uuidv7 } from "./uuid/src/index.js"; +import { warnOnce } from "./warn.js"; +import { XXH3_128, xxh128ToBytes } from "./xxhash/xxhash.js"; + +let UUID7_WARNING_EMITTED = false; + +export function assertUuid(str: string, which?: string): string { + // Use relaxed regex validation instead of strict uuid.validate() + // This allows edge cases like nil UUIDs or test UUIDs that might not pass strict validation + if (!UUID_REGEX.test(str)) { + const msg = + which !== undefined + ? `Invalid UUID for ${which}: ${str}` + : `Invalid UUID: ${str}`; + throw new Error(msg); + } + return str; +} + +/** + * Generate a UUID v7 from a timestamp. + * + * @param timestamp - The timestamp in milliseconds + * @returns A UUID v7 string + */ +export function uuid7FromTime(timestamp: number | string): string { + const msecs = + typeof timestamp === "string" ? Date.parse(timestamp) : timestamp; + // Work around uuid@10 behavior where providing only { msecs } + // may not set the internal timestamp used for stringification. + // Providing a seq ensures the implementation updates its internal state + // and encodes the provided milliseconds into the UUID bytes. + return uuidv7({ msecs, seq: 0 }); +} + +/** + * Get the version of a UUID string. + * @param uuidStr - The UUID string to check + * @returns The version number (1-7) or null if invalid + */ +export function getUuidVersion(uuidStr: string): number | null { + if (!UUID_REGEX.test(uuidStr)) { + return null; + } + + // Version is in bits 48-51 + // Format: xxxxxxxx-xxxx-Vxxx-xxxx-xxxxxxxxxxxx + const versionChar = uuidStr[14]; + return parseInt(versionChar, 16); +} + +/** + * Warn if a UUID is not version 7. + * + * @param uuidStr - The UUID string to check + * @param idType - The type of ID (e.g., "run_id", "trace_id") for the warning message + */ +export function warnIfNotUuidV7(uuidStr: string, _idType: string): void { + const version = getUuidVersion(uuidStr); + if (version !== null && version !== 7 && !UUID7_WARNING_EMITTED) { + UUID7_WARNING_EMITTED = true; + warnOnce( + `LangSmith now uses UUID v7 for run and trace identifiers. ` + + `This warning appears when passing custom IDs. ` + + `Please use: import { uuidv7 } from 'langsmith'; const id = uuidv7(); ` + + `Future versions will require UUID v7.`, + ); + } +} + +/** + * Convert a UUID string to its 16-byte representation. + * @param uuidStr - The UUID string (with or without dashes) + * @returns A Uint8Array containing the 16 bytes of the UUID + */ +function uuidToBytes(uuidStr: string): Uint8Array { + const hex = uuidStr.replace(/-/g, ""); + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Convert 16 bytes to a UUID string. + * @param bytes - A Uint8Array containing 16 bytes + * @returns A UUID string in standard format + */ +function bytesToUuid(bytes: Uint8Array): string { + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice( + 12, + 16, + )}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +// Reuse TextEncoder instance for performance +const _textEncoder = new TextEncoder(); + +/** + * Generates a 16-byte fingerprint for deterministic UUID generation using XXH3-128. + * + * XXH3 is an extremely fast, non-cryptographic hash function that provides excellent + * collision resistance. It's widely used in production systems and compatible with + * xxHash implementations in other languages. + * + * See: https://github.com/Cyan4973/xxHash + * + * @param str - The input string to hash + * @returns A Uint8Array containing 16 bytes of hash output + */ +function _fastHash128(str: string): Uint8Array { + const data = _textEncoder.encode(str); + + // Compute XXH3-128 hash and convert to bytes + const hash128 = XXH3_128(data); + return xxh128ToBytes(hash128); +} + +/** + * Generate a deterministic UUID v7 derived from an original UUID and a key. + * + * A pure function of `originalId` and `key`: it reads no ambient state, so the + * same pair always yields the same UUID, in any process, at any time. This is + * used for creating replica ids that are reproducible across distributed + * systems, including across SDK implementations. + * + * A UUID v7 original keeps its timestamp, so derived ids stay ordered by run + * start time. Any other original has none to keep, and the field is + * hash-derived: stable, but not chronological. + * + * @param originalId - The source UUID string (ideally UUID v7 to preserve timestamp) + * @param key - A string key used for deterministic derivation (e.g., project name) + * @returns A new UUID v7 string with preserved timestamp (if original is v7) and + * deterministic random bits + * + * @example + * ```typescript + * const original = uuidv7(); + * const replicaId = nonCryptographicUuid7Deterministic(original, "replica-project"); + * // Same inputs always produce same output + * assert(nonCryptographicUuid7Deterministic(original, "replica-project") === replicaId); + * ``` + */ +export function nonCryptographicUuid7Deterministic( + originalId: string, + key: string, +): string { + // Generate deterministic bytes from hash of original + key + const hashInput = `${originalId}:${key}`; + const h = _fastHash128(hashInput); + + // Build new UUID7: + // UUID7 structure (RFC 9562): + // [0-5] 48 bits: unix_ts_ms (timestamp in milliseconds) + // [6] 4 bits: version (0111 = 7) + 4 bits rand_a + // [7] 8 bits: rand_a (continued) + // [8] 2 bits: variant (10) + 6 bits rand_b + // [9-15] 56 bits: rand_b (continued) + + const b = new Uint8Array(16); + + const version = getUuidVersion(originalId); + if (version === 7) { + // Preserve timestamp from original UUID7 (bytes 0-5) + const originalBytes = uuidToBytes(originalId); + b.set(originalBytes.slice(0, 6), 0); + } else { + // No timestamp to preserve. Use the six hash bytes left unused below: a + // clock read here would make this impure, and the same id is derived + // from several runs, all of which must agree. + b.set(h.slice(10, 16), 0); + } + + // Set version 7 (0111) in high nibble + 4 bits from hash + b[6] = 0x70 | (h[0] & 0x0f); + + // rand_a continued (8 bits from hash) + b[7] = h[1]; + + // Set variant (10) in high 2 bits + 6 bits from hash + b[8] = 0x80 | (h[2] & 0x3f); + + // rand_b (56 bits = 7 bytes from hash) + b.set(h.slice(3, 10), 9); + + return bytesToUuid(b); +} diff --git a/src/lib/utils/asserts.ts b/src/lib/utils/asserts.ts new file mode 100644 index 0000000..f34498a --- /dev/null +++ b/src/lib/utils/asserts.ts @@ -0,0 +1,50 @@ +export function isPromiseMethod( + x: string | symbol, +): x is "then" | "catch" | "finally" { + if (x === "then" || x === "catch" || x === "finally") { + return true; + } + return false; +} + +export function isKVMap(x: unknown): x is Record { + if (typeof x !== "object" || x == null) { + return false; + } + + const prototype = Object.getPrototypeOf(x); + return ( + (prototype === null || + prototype === Object.prototype || + Object.getPrototypeOf(prototype) === null) && + !(Symbol.toStringTag in x) && + !(Symbol.iterator in x) + ); +} +export const isAsyncIterable = (x: unknown): x is AsyncIterable => + x != null && + typeof x === "object" && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (x as any)[Symbol.asyncIterator] === "function"; + +export const isIteratorLike = (x: unknown): x is Iterator => + x != null && + typeof x === "object" && + "next" in x && + typeof x.next === "function"; + +const GeneratorFunction = function* () {}.constructor; +export const isGenerator = (x: unknown): x is Generator => + x != null && typeof x === "function" && x instanceof GeneratorFunction; + +export const isThenable = (x: unknown): x is Promise => + x != null && + typeof x === "object" && + "then" in x && + typeof x.then === "function"; + +export const isReadableStream = (x: unknown): x is ReadableStream => + x != null && + typeof x === "object" && + "getReader" in x && + typeof x.getReader === "function"; diff --git a/src/lib/utils/async_caller.ts b/src/lib/utils/async_caller.ts new file mode 100644 index 0000000..626dbe9 --- /dev/null +++ b/src/lib/utils/async_caller.ts @@ -0,0 +1,206 @@ +import pRetry from "../utils/p-retry/index.js"; +import { PQueue, PQueueType } from "./p-queue.js"; + +const STATUS_RETRYABLE = [ + 408, // Request Timeout + 425, // Too Early + 429, // Too Many Requests + 500, // Internal Server Error + 502, // Bad Gateway + 503, // Service Unavailable + 504, // Gateway Timeout +]; + +type ResponseCallback = (response?: Response) => Promise; + +export interface AsyncCallerParams { + /** + * The maximum number of concurrent calls that can be made. + * Defaults to `Infinity`, which means no limit. + */ + maxConcurrency?: number; + /** + * The maximum number of retries that can be made for a single call, + * with an exponential backoff between each attempt. Defaults to 6. + */ + maxRetries?: number; + /** + * The maximum size of the queue buffer in bytes. When the queue reaches this size, + * new calls will be dropped instead of queued. + * If not specified, no limit is enforced. + */ + maxQueueSizeBytes?: number; + + onFailedResponseHook?: ResponseCallback; + + debug?: boolean; +} + +export interface AsyncCallerCallOptions { + signal?: AbortSignal; + /** + * The size of this call in bytes, used for queue size tracking. + * If not provided, size tracking is skipped for this call. + */ + sizeBytes?: number; +} + +/** + * A class that can be used to make async calls with concurrency and retry logic. + * + * This is useful for making calls to any kind of "expensive" external resource, + * be it because it's rate-limited, subject to network issues, etc. + * + * Concurrent calls are limited by the `maxConcurrency` parameter, which defaults + * to `Infinity`. This means that by default, all calls will be made in parallel. + * + * Retries are limited by the `maxRetries` parameter, which defaults to 6. This + * means that by default, each call will be retried up to 6 times, with an + * exponential backoff between each attempt. + */ +export class AsyncCaller { + protected maxConcurrency: AsyncCallerParams["maxConcurrency"]; + + protected maxRetries: AsyncCallerParams["maxRetries"]; + + protected maxQueueSizeBytes: AsyncCallerParams["maxQueueSizeBytes"]; + + queue: PQueueType; + + private onFailedResponseHook?: ResponseCallback; + + private queueSizeBytes = 0; + + constructor(params: AsyncCallerParams) { + this.maxConcurrency = params.maxConcurrency ?? Infinity; + this.maxRetries = params.maxRetries ?? 6; + this.maxQueueSizeBytes = params.maxQueueSizeBytes; + + this.queue = new PQueue({ concurrency: this.maxConcurrency }); + this.onFailedResponseHook = params?.onFailedResponseHook; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + call Promise>( + callable: T, + ...args: Parameters + ): Promise>> { + return this.callWithOptions({}, callable, ...args); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callWithOptions Promise>( + options: AsyncCallerCallOptions, + callable: T, + ...args: Parameters + ): Promise>> { + const sizeBytes = options.sizeBytes ?? 0; + + // Check if adding this call would exceed the byte size limit + if ( + this.maxQueueSizeBytes !== undefined && + sizeBytes > 0 && + this.queueSizeBytes + sizeBytes > this.maxQueueSizeBytes + ) { + return Promise.reject( + new Error( + `Queue size limit (${this.maxQueueSizeBytes} bytes) exceeded. ` + + `Current queue size: ${this.queueSizeBytes} bytes, attempted addition: ${sizeBytes} bytes.`, + ), + ); + } + + // Add to queue size tracking + if (sizeBytes > 0) { + this.queueSizeBytes += sizeBytes; + } + + const onFailedResponseHook = this.onFailedResponseHook; + let promise = this.queue.add( + () => + pRetry( + () => + callable(...(args as Parameters)).catch((error) => { + if (error instanceof Error) { + throw error; + } else { + throw new Error(error); + } + }), + { + async onFailedAttempt({ error }: { error: unknown }) { + // Rethrow the value if it's not an object + if (typeof error !== "object" || error == null) throw error; + + const errorMessage = + "message" in error && typeof error.message === "string" + ? error.message + : undefined; + + if ( + errorMessage?.startsWith("Cancel") || + errorMessage?.startsWith("TimeoutError") || + errorMessage?.startsWith("AbortError") + ) { + throw error; + } + + if ("name" in error && error.name === "TimeoutError") { + throw error; + } + + if ("code" in error && error.code === "ECONNABORTED") { + throw error; + } + + const response = + "response" in error + ? (error.response as Response | undefined) + : undefined; + + if (onFailedResponseHook) { + const handled = await onFailedResponseHook(response); + if (handled) return; + } + + const status = + response?.status ?? + ("status" in error ? error.status : undefined); + + if ( + status != null && + (typeof status === "number" || typeof status === "string") && + !STATUS_RETRYABLE.includes(+status) + ) { + throw error; + } + }, + retries: this.maxRetries, + randomize: true, + }, + ), + { throwOnTimeout: true }, + ); + + // Decrement queue size when the call completes (success or failure) + if (sizeBytes > 0) { + promise = promise.finally(() => { + this.queueSizeBytes -= sizeBytes; + }); + } + + // Handle signal cancellation + if (options.signal) { + return Promise.race([ + promise, + new Promise((_, reject) => { + options.signal?.addEventListener("abort", () => { + reject(new Error("AbortError")); + }); + }), + ]); + } + + return promise; + } +} diff --git a/src/lib/utils/atee.ts b/src/lib/utils/atee.ts new file mode 100644 index 0000000..313ca23 --- /dev/null +++ b/src/lib/utils/atee.ts @@ -0,0 +1,24 @@ +export function atee( + iter: AsyncGenerator, + length = 2, +): AsyncGenerator[] { + const buffers = Array.from( + { length }, + () => [] as Array | IteratorReturnResult>, + ); + return buffers.map(async function* makeIter(buffer) { + while (true) { + if (buffer.length === 0) { + const result = await iter.next(); + for (const buffer of buffers) { + buffer.push(result); + } + } else if (buffer[0].done) { + return; + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + yield buffer.shift()!.value; + } + } + }); +} diff --git a/src/lib/utils/chalk/license b/src/lib/utils/chalk/license new file mode 100644 index 0000000..fa7ceba --- /dev/null +++ b/src/lib/utils/chalk/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lib/utils/chalk/source/index.ts b/src/lib/utils/chalk/source/index.ts new file mode 100644 index 0000000..f9e9a5b --- /dev/null +++ b/src/lib/utils/chalk/source/index.ts @@ -0,0 +1,227 @@ +/* eslint-disable */ +// @ts-nocheck +import ansiStyles from './vendor/ansi-styles/index.js'; +import supportsColor from './vendor/supports-color/index.js'; +import { + stringReplaceAll, + stringEncaseCRLFWithFirstIndex, +} from './utilities.js'; + +const {stdout: stdoutColor, stderr: stderrColor} = supportsColor; + +const GENERATOR = Symbol('GENERATOR'); +const STYLER = Symbol('STYLER'); +const IS_EMPTY = Symbol('IS_EMPTY'); + +// `supportsColor.level` → `ansiStyles.color[name]` mapping +const levelMapping = [ + 'ansi', + 'ansi', + 'ansi256', + 'ansi16m', +]; + +const styles = Object.create(null); + +const applyOptions = (object, options = {}) => { + if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) { + throw new Error('The `level` option should be an integer from 0 to 3'); + } + + // Detect level if not set manually + const colorLevel = stdoutColor ? stdoutColor.level : 0; + object.level = options.level === undefined ? colorLevel : options.level; +}; + +export class Chalk { + constructor(options) { + // eslint-disable-next-line no-constructor-return + return chalkFactory(options); + } +} + +const chalkFactory = options => { + const chalk = (...strings) => strings.join(' '); + applyOptions(chalk, options); + + Object.setPrototypeOf(chalk, createChalk.prototype); + + return chalk; +}; + +function createChalk(options) { + return chalkFactory(options); +} + +Object.setPrototypeOf(createChalk.prototype, Function.prototype); + +for (const [styleName, style] of Object.entries(ansiStyles)) { + styles[styleName] = { + get() { + const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]); + Object.defineProperty(this, styleName, {value: builder}); + return builder; + }, + }; +} + +styles.visible = { + get() { + const builder = createBuilder(this, this[STYLER], true); + Object.defineProperty(this, 'visible', {value: builder}); + return builder; + }, +}; + +const getModelAnsi = (model, level, type, ...arguments_) => { + if (model === 'rgb') { + if (level === 'ansi16m') { + return ansiStyles[type].ansi16m(...arguments_); + } + + if (level === 'ansi256') { + return ansiStyles[type].ansi256(ansiStyles.rgbToAnsi256(...arguments_)); + } + + return ansiStyles[type].ansi(ansiStyles.rgbToAnsi(...arguments_)); + } + + if (model === 'hex') { + return getModelAnsi('rgb', level, type, ...ansiStyles.hexToRgb(...arguments_)); + } + + return ansiStyles[type][model](...arguments_); +}; + +const usedModels = ['rgb', 'hex', 'ansi256']; + +for (const model of usedModels) { + styles[model] = { + get() { + const {level} = this; + return function (...arguments_) { + const styler = createStyler(getModelAnsi(model, levelMapping[level], 'color', ...arguments_), ansiStyles.color.close, this[STYLER]); + return createBuilder(this, styler, this[IS_EMPTY]); + }; + }, + }; + + const bgModel = 'bg' + model[0].toUpperCase() + model.slice(1); + styles[bgModel] = { + get() { + const {level} = this; + return function (...arguments_) { + const styler = createStyler(getModelAnsi(model, levelMapping[level], 'bgColor', ...arguments_), ansiStyles.bgColor.close, this[STYLER]); + return createBuilder(this, styler, this[IS_EMPTY]); + }; + }, + }; +} + +const proto = Object.defineProperties(() => {}, { + ...styles, + level: { + enumerable: true, + get() { + return this[GENERATOR].level; + }, + set(level) { + this[GENERATOR].level = level; + }, + }, +}); + +const createStyler = (open, close, parent) => { + let openAll; + let closeAll; + if (parent === undefined) { + openAll = open; + closeAll = close; + } else { + openAll = parent.openAll + open; + closeAll = close + parent.closeAll; + } + + return { + open, + close, + openAll, + closeAll, + parent, + }; +}; + +const createBuilder = (self, _styler, _isEmpty) => { + // Single argument is hot path, implicit coercion is faster than anything + // eslint-disable-next-line no-implicit-coercion + const builder = (...arguments_) => applyStyle(builder, (arguments_.length === 1) ? ('' + arguments_[0]) : arguments_.join(' ')); + + // We alter the prototype because we must return a function, but there is + // no way to create a function with a different prototype + Object.setPrototypeOf(builder, proto); + + builder[GENERATOR] = self; + builder[STYLER] = _styler; + builder[IS_EMPTY] = _isEmpty; + + return builder; +}; + +const applyStyle = (self, string) => { + if (self.level <= 0 || !string) { + return self[IS_EMPTY] ? '' : string; + } + + let styler = self[STYLER]; + + if (styler === undefined) { + return string; + } + + const {openAll, closeAll} = styler; + if (string.includes('\u001B')) { + while (styler !== undefined) { + // Replace any instances already present with a re-opening code + // otherwise only the part of the string until said closing code + // will be colored, and the rest will simply be 'plain'. + string = stringReplaceAll(string, styler.close, styler.open); + + styler = styler.parent; + } + } + + // We can move both next actions out of loop, because remaining actions in loop won't have + // any/visible effect on parts we add here. Close the styling before a linebreak and reopen + // after next line to fix a bleed issue on macOS: https://github.com/chalk/chalk/pull/92 + const lfIndex = string.indexOf('\n'); + if (lfIndex !== -1) { + string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex); + } + + return openAll + string + closeAll; +}; + +Object.defineProperties(createChalk.prototype, styles); + +const chalk = createChalk(); +export const chalkStderr = createChalk({level: stderrColor ? stderrColor.level : 0}); + +export { + modifierNames, + foregroundColorNames, + backgroundColorNames, + colorNames, + + // TODO: Remove these aliases in the next major version + modifierNames as modifiers, + foregroundColorNames as foregroundColors, + backgroundColorNames as backgroundColors, + colorNames as colors, +} from './vendor/ansi-styles/index.js'; + +export { + stdoutColor as supportsColor, + stderrColor as supportsColorStderr, +}; + +export default chalk as any; diff --git a/src/lib/utils/chalk/source/utilities.ts b/src/lib/utils/chalk/source/utilities.ts new file mode 100644 index 0000000..381fa79 --- /dev/null +++ b/src/lib/utils/chalk/source/utilities.ts @@ -0,0 +1,35 @@ +/* eslint-disable */ +// @ts-nocheck +// TODO: When targeting Node.js 16, use `String.prototype.replaceAll`. +export function stringReplaceAll(string, substring, replacer) { + let index = string.indexOf(substring); + if (index === -1) { + return string; + } + + const substringLength = substring.length; + let endIndex = 0; + let returnValue = ''; + do { + returnValue += string.slice(endIndex, index) + substring + replacer; + endIndex = index + substringLength; + index = string.indexOf(substring, endIndex); + } while (index !== -1); + + returnValue += string.slice(endIndex); + return returnValue; +} + +export function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) { + let endIndex = 0; + let returnValue = ''; + do { + const gotCR = string[index - 1] === '\r'; + returnValue += string.slice(endIndex, (gotCR ? index - 1 : index)) + prefix + (gotCR ? '\r\n' : '\n') + postfix; + endIndex = index + 1; + index = string.indexOf('\n', endIndex); + } while (index !== -1); + + returnValue += string.slice(endIndex); + return returnValue; +} diff --git a/src/lib/utils/chalk/source/vendor/ansi-styles/index.ts b/src/lib/utils/chalk/source/vendor/ansi-styles/index.ts new file mode 100644 index 0000000..e53b36b --- /dev/null +++ b/src/lib/utils/chalk/source/vendor/ansi-styles/index.ts @@ -0,0 +1,225 @@ +/* eslint-disable */ +// @ts-nocheck +const ANSI_BACKGROUND_OFFSET = 10; + +const wrapAnsi16 = (offset = 0) => code => `\u001B[${code + offset}m`; + +const wrapAnsi256 = (offset = 0) => code => `\u001B[${38 + offset};5;${code}m`; + +const wrapAnsi16m = (offset = 0) => (red, green, blue) => `\u001B[${38 + offset};2;${red};${green};${blue}m`; + +const styles = { + modifier: { + reset: [0, 0], + // 21 isn't widely supported and 22 does the same thing + bold: [1, 22], + dim: [2, 22], + italic: [3, 23], + underline: [4, 24], + overline: [53, 55], + inverse: [7, 27], + hidden: [8, 28], + strikethrough: [9, 29], + }, + color: { + black: [30, 39], + red: [31, 39], + green: [32, 39], + yellow: [33, 39], + blue: [34, 39], + magenta: [35, 39], + cyan: [36, 39], + white: [37, 39], + + // Bright color + blackBright: [90, 39], + gray: [90, 39], // Alias of `blackBright` + grey: [90, 39], // Alias of `blackBright` + redBright: [91, 39], + greenBright: [92, 39], + yellowBright: [93, 39], + blueBright: [94, 39], + magentaBright: [95, 39], + cyanBright: [96, 39], + whiteBright: [97, 39], + }, + bgColor: { + bgBlack: [40, 49], + bgRed: [41, 49], + bgGreen: [42, 49], + bgYellow: [43, 49], + bgBlue: [44, 49], + bgMagenta: [45, 49], + bgCyan: [46, 49], + bgWhite: [47, 49], + + // Bright color + bgBlackBright: [100, 49], + bgGray: [100, 49], // Alias of `bgBlackBright` + bgGrey: [100, 49], // Alias of `bgBlackBright` + bgRedBright: [101, 49], + bgGreenBright: [102, 49], + bgYellowBright: [103, 49], + bgBlueBright: [104, 49], + bgMagentaBright: [105, 49], + bgCyanBright: [106, 49], + bgWhiteBright: [107, 49], + }, +}; + +export const modifierNames = Object.keys(styles.modifier); +export const foregroundColorNames = Object.keys(styles.color); +export const backgroundColorNames = Object.keys(styles.bgColor); +export const colorNames = [...foregroundColorNames, ...backgroundColorNames]; + +function assembleStyles() { + const codes = new Map(); + + for (const [groupName, group] of Object.entries(styles)) { + for (const [styleName, style] of Object.entries(group)) { + styles[styleName] = { + open: `\u001B[${style[0]}m`, + close: `\u001B[${style[1]}m`, + }; + + group[styleName] = styles[styleName]; + + codes.set(style[0], style[1]); + } + + Object.defineProperty(styles, groupName, { + value: group, + enumerable: false, + }); + } + + Object.defineProperty(styles, 'codes', { + value: codes, + enumerable: false, + }); + + styles.color.close = '\u001B[39m'; + styles.bgColor.close = '\u001B[49m'; + + styles.color.ansi = wrapAnsi16(); + styles.color.ansi256 = wrapAnsi256(); + styles.color.ansi16m = wrapAnsi16m(); + styles.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET); + styles.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET); + styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET); + + // From https://github.com/Qix-/color-convert/blob/3f0e0d4e92e235796ccb17f6e85c72094a651f49/conversions.js + Object.defineProperties(styles, { + rgbToAnsi256: { + value(red, green, blue) { + // We use the extended greyscale palette here, with the exception of + // black and white. normal palette only has 4 greyscale shades. + if (red === green && green === blue) { + if (red < 8) { + return 16; + } + + if (red > 248) { + return 231; + } + + return Math.round(((red - 8) / 247) * 24) + 232; + } + + return 16 + + (36 * Math.round(red / 255 * 5)) + + (6 * Math.round(green / 255 * 5)) + + Math.round(blue / 255 * 5); + }, + enumerable: false, + }, + hexToRgb: { + value(hex) { + const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16)); + if (!matches) { + return [0, 0, 0]; + } + + let [colorString] = matches; + + if (colorString.length === 3) { + colorString = [...colorString].map(character => character + character).join(''); + } + + const integer = Number.parseInt(colorString, 16); + + return [ + /* eslint-disable no-bitwise */ + (integer >> 16) & 0xFF, + (integer >> 8) & 0xFF, + integer & 0xFF, + /* eslint-enable no-bitwise */ + ]; + }, + enumerable: false, + }, + hexToAnsi256: { + value: hex => styles.rgbToAnsi256(...styles.hexToRgb(hex)), + enumerable: false, + }, + ansi256ToAnsi: { + value(code) { + if (code < 8) { + return 30 + code; + } + + if (code < 16) { + return 90 + (code - 8); + } + + let red; + let green; + let blue; + + if (code >= 232) { + red = (((code - 232) * 10) + 8) / 255; + green = red; + blue = red; + } else { + code -= 16; + + const remainder = code % 36; + + red = Math.floor(code / 36) / 5; + green = Math.floor(remainder / 6) / 5; + blue = (remainder % 6) / 5; + } + + const value = Math.max(red, green, blue) * 2; + + if (value === 0) { + return 30; + } + + // eslint-disable-next-line no-bitwise + let result = 30 + ((Math.round(blue) << 2) | (Math.round(green) << 1) | Math.round(red)); + + if (value === 2) { + result += 60; + } + + return result; + }, + enumerable: false, + }, + rgbToAnsi: { + value: (red, green, blue) => styles.ansi256ToAnsi(styles.rgbToAnsi256(red, green, blue)), + enumerable: false, + }, + hexToAnsi: { + value: hex => styles.ansi256ToAnsi(styles.hexToAnsi256(hex)), + enumerable: false, + }, + }); + + return styles; +} + +const ansiStyles = assembleStyles(); + +export default ansiStyles; diff --git a/src/lib/utils/chalk/source/vendor/supports-color/browser.ts b/src/lib/utils/chalk/source/vendor/supports-color/browser.ts new file mode 100644 index 0000000..7e6f968 --- /dev/null +++ b/src/lib/utils/chalk/source/vendor/supports-color/browser.ts @@ -0,0 +1,36 @@ +/* eslint-disable */ +// @ts-nocheck +/* eslint-env browser */ + +const level = (() => { + if (!('navigator' in globalThis)) { + return 0; + } + + if (globalThis.navigator.userAgentData) { + const brand = navigator.userAgentData.brands.find(({brand}) => brand === 'Chromium'); + if (brand && brand.version > 93) { + return 3; + } + } + + if (/\b(Chrome|Chromium)\//.test(globalThis.navigator.userAgent)) { + return 1; + } + + return 0; +})(); + +const colorSupport = level !== 0 && { + level, + hasBasic: true, + has256: level >= 2, + has16m: level >= 3, +}; + +const supportsColor = { + stdout: colorSupport, + stderr: colorSupport, +}; + +export default supportsColor; diff --git a/src/lib/utils/chalk/source/vendor/supports-color/index.ts b/src/lib/utils/chalk/source/vendor/supports-color/index.ts new file mode 100644 index 0000000..8df874e --- /dev/null +++ b/src/lib/utils/chalk/source/vendor/supports-color/index.ts @@ -0,0 +1,192 @@ +/* eslint-disable */ +// @ts-nocheck +import process from 'node:process'; +import os from 'node:os'; +import tty from 'node:tty'; + +// From: https://github.com/sindresorhus/has-flag/blob/main/index.js +/// function hasFlag(flag, argv = globalThis.Deno?.args ?? process.argv) { +function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process.argv) { + const prefix = flag.startsWith('-') ? '' : (flag.length === 1 ? '-' : '--'); + const position = argv.indexOf(prefix + flag); + const terminatorPosition = argv.indexOf('--'); + return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition); +} + +const {env} = process; + +let flagForceColor; +if ( + hasFlag('no-color') + || hasFlag('no-colors') + || hasFlag('color=false') + || hasFlag('color=never') +) { + flagForceColor = 0; +} else if ( + hasFlag('color') + || hasFlag('colors') + || hasFlag('color=true') + || hasFlag('color=always') +) { + flagForceColor = 1; +} + +function envForceColor() { + if ('FORCE_COLOR' in env) { + if (env.FORCE_COLOR === 'true') { + return 1; + } + + if (env.FORCE_COLOR === 'false') { + return 0; + } + + return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3); + } +} + +function translateLevel(level) { + if (level === 0) { + return false; + } + + return { + level, + hasBasic: true, + has256: level >= 2, + has16m: level >= 3, + }; +} + +function _supportsColor(haveStream, {streamIsTTY, sniffFlags = true} = {}) { + const noFlagForceColor = envForceColor(); + if (noFlagForceColor !== undefined) { + flagForceColor = noFlagForceColor; + } + + const forceColor = sniffFlags ? flagForceColor : noFlagForceColor; + + if (forceColor === 0) { + return 0; + } + + if (sniffFlags) { + if (hasFlag('color=16m') + || hasFlag('color=full') + || hasFlag('color=truecolor')) { + return 3; + } + + if (hasFlag('color=256')) { + return 2; + } + } + + // Check for Azure DevOps pipelines. + // Has to be above the `!streamIsTTY` check. + if ('TF_BUILD' in env && 'AGENT_NAME' in env) { + return 1; + } + + if (haveStream && !streamIsTTY && forceColor === undefined) { + return 0; + } + + const min = forceColor || 0; + + if (env.TERM === 'dumb') { + return min; + } + + if (process.platform === 'win32') { + // Windows 10 build 10586 is the first Windows release that supports 256 colors. + // Windows 10 build 14931 is the first release that supports 16m/TrueColor. + const osRelease = os.release().split('.'); + if ( + Number(osRelease[0]) >= 10 + && Number(osRelease[2]) >= 10_586 + ) { + return Number(osRelease[2]) >= 14_931 ? 3 : 2; + } + + return 1; + } + + if ('CI' in env) { + if (['GITHUB_ACTIONS', 'GITEA_ACTIONS', 'CIRCLECI'].some(key => key in env)) { + return 3; + } + + if (['TRAVIS', 'APPVEYOR', 'GITLAB_CI', 'BUILDKITE', 'DRONE'].some(sign => sign in env) || env.CI_NAME === 'codeship') { + return 1; + } + + return min; + } + + if ('TEAMCITY_VERSION' in env) { + return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0; + } + + if (env.COLORTERM === 'truecolor') { + return 3; + } + + if (env.TERM === 'xterm-kitty') { + return 3; + } + + if (env.TERM === 'xterm-ghostty') { + return 3; + } + + if (env.TERM === 'wezterm') { + return 3; + } + + if ('TERM_PROGRAM' in env) { + const version = Number.parseInt((env.TERM_PROGRAM_VERSION || '').split('.')[0], 10); + + switch (env.TERM_PROGRAM) { + case 'iTerm.app': { + return version >= 3 ? 3 : 2; + } + + case 'Apple_Terminal': { + return 2; + } + // No default + } + } + + if (/-256(color)?$/i.test(env.TERM)) { + return 2; + } + + if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) { + return 1; + } + + if ('COLORTERM' in env) { + return 1; + } + + return min; +} + +export function createSupportsColor(stream, options = {}) { + const level = _supportsColor(stream, { + streamIsTTY: stream && stream.isTTY, + ...options, + }); + + return translateLevel(level); +} + +const supportsColor = { + stdout: createSupportsColor({isTTY: tty.isatty(1)}), + stderr: createSupportsColor({isTTY: tty.isatty(2)}), +}; + +export default supportsColor; diff --git a/src/lib/utils/console-table-printer/LICENSE b/src/lib/utils/console-table-printer/LICENSE new file mode 100644 index 0000000..56cc059 --- /dev/null +++ b/src/lib/utils/console-table-printer/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Nahiyan Kamal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib/utils/console-table-printer/index.ts b/src/lib/utils/console-table-printer/index.ts new file mode 100644 index 0000000..926706d --- /dev/null +++ b/src/lib/utils/console-table-printer/index.ts @@ -0,0 +1,9 @@ +import Table from './src/console-table-printer.js'; +import { + printSimpleTable as printTable, + renderSimpleTable as renderTable, +} from './src/internalTable/internal-table-printer.js'; + +import { COLOR, ALIGNMENT } from './src/models/external-table.js'; + +export { Table, printTable, renderTable, COLOR, ALIGNMENT }; diff --git a/src/lib/utils/console-table-printer/src/console-table-printer.ts b/src/lib/utils/console-table-printer/src/console-table-printer.ts new file mode 100644 index 0000000..d0731c2 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/console-table-printer.ts @@ -0,0 +1,47 @@ +import TableInternal from './internalTable/internal-table.js'; +import { Dictionary } from './models/common.js'; +import { ColumnOptionsRaw, ComplexOptions } from './models/external-table.js'; +import { + convertRawRowOptionsToStandard, + RowOptionsRaw, +} from './utils/table-helpers.js'; + +export default class Table { + table: TableInternal; + + constructor(options?: ComplexOptions | string[]) { + this.table = new TableInternal(options); + } + + addColumn(column: string | ColumnOptionsRaw) { + this.table.addColumn(column); + return this; + } + + addColumns(columns: string[] | ColumnOptionsRaw[]) { + this.table.addColumns(columns); + return this; + } + + addRow(text: Dictionary, rowOptions?: RowOptionsRaw) { + this.table.addRow(text, convertRawRowOptionsToStandard(rowOptions)); + return this; + } + + addRows(toBeInsertedRows: Dictionary[], rowOptions?: RowOptionsRaw) { + this.table.addRows( + toBeInsertedRows, + convertRawRowOptionsToStandard(rowOptions) + ); + return this; + } + + printTable() { + const tableRendered = this.table.renderTable(); + console.log(tableRendered); + } + + render() { + return this.table.renderTable(); + } +} diff --git a/src/lib/utils/console-table-printer/src/internalTable/input-converter.ts b/src/lib/utils/console-table-printer/src/internalTable/input-converter.ts new file mode 100644 index 0000000..858dc8a --- /dev/null +++ b/src/lib/utils/console-table-printer/src/internalTable/input-converter.ts @@ -0,0 +1,42 @@ +import { ALIGNMENT, COLOR } from '../models/common.js'; +import { + ColumnOptionsRaw, + ComputedColumn, + DefaultColumnOptions, +} from '../models/external-table.js'; +import { Column } from '../models/internal-table.js'; +import { DEFAULT_ROW_ALIGNMENT } from '../utils/table-constants.js'; + +export const objIfExists = (key: string, val: any) => { + if (!val) { + return {}; + } + + return { + [key]: val, + }; +}; + +export const rawColumnToInternalColumn = ( + column: ColumnOptionsRaw | ComputedColumn, + defaultColumnStyles?: DefaultColumnOptions +): Column => ({ + name: column.name, + title: column.title ?? column.name, + ...objIfExists( + 'color', + (column.color || defaultColumnStyles?.color) as COLOR + ), + ...objIfExists( + 'maxLen', + (column.maxLen || defaultColumnStyles?.maxLen) as number + ), + ...objIfExists( + 'minLen', + (column.minLen || defaultColumnStyles?.minLen) as number + ), + alignment: (column.alignment || + defaultColumnStyles?.alignment || + DEFAULT_ROW_ALIGNMENT) as ALIGNMENT, + transform: column.transform, +}); diff --git a/src/lib/utils/console-table-printer/src/internalTable/internal-table-printer.ts b/src/lib/utils/console-table-printer/src/internalTable/internal-table-printer.ts new file mode 100644 index 0000000..fa501cf --- /dev/null +++ b/src/lib/utils/console-table-printer/src/internalTable/internal-table-printer.ts @@ -0,0 +1,268 @@ +import { CharLengthDict, Dictionary, Row } from '../models/common.js'; +import { ComplexOptions } from '../models/external-table.js'; +import { Column, TableStyleDetails } from '../models/internal-table.js'; +import ColoredConsoleLine, { ColorMap } from '../utils/colored-console-line.js'; +import { textWithPadding } from '../utils/string-utils.js'; +import { + DEFAULT_COLUMN_LEN, + DEFAULT_HEADER_ALIGNMENT, + DEFAULT_HEADER_FONT_COLOR, + DEFAULT_ROW_ALIGNMENT, + DEFAULT_ROW_FONT_COLOR, +} from '../utils/table-constants.js'; +import { + cellText, + createHeaderAsRow, + createRow, + getWidthLimitedColumnsArray, + renderTableHorizontalBorders, +} from '../utils/table-helpers.js'; +import TableInternal from './internal-table.js'; +import { preProcessColumns, preProcessRows } from './table-pre-processors.js'; + +// ║ Index ║ ║ ║ +const renderOneLine = ( + tableStyle: TableStyleDetails, + columns: Column[], + currentLineIndex: number, + widthLimitedColumnsArray: { [key: string]: string[] }, + isHeader: boolean | undefined, + row: Row, + colorMap: ColorMap, + charLength?: CharLengthDict +): string => { + const line = new ColoredConsoleLine(colorMap); + line.addCharsWithColor('', tableStyle.vertical); // dont Color the Column borders + columns.forEach((column) => { + const thisLineHasText = + currentLineIndex < widthLimitedColumnsArray[column.name].length; + + const textForThisLine: string = thisLineHasText + ? cellText(widthLimitedColumnsArray[column.name][currentLineIndex]) + : ''; + + line.addCharsWithColor(DEFAULT_ROW_FONT_COLOR, ' '); + line.addCharsWithColor( + (isHeader && DEFAULT_HEADER_FONT_COLOR) || column.color || row.color, + textWithPadding( + textForThisLine, + column.alignment || DEFAULT_ROW_ALIGNMENT, + column.length || DEFAULT_COLUMN_LEN, + charLength + ) + ); + line.addCharsWithColor('', ` ${tableStyle.vertical}`); // dont Color the Column borders + }); + return line.renderConsole(); +}; + +// ║ Bold ║ text ║ value ║ +// ║ Index ║ ║ ║ +const renderWidthLimitedLines = ( + tableStyle: TableStyleDetails, + columns: Column[], + row: Row, + colorMap: ColorMap, + isHeader?: boolean, + charLength?: CharLengthDict +): string[] => { + // { col1: ['How', 'Is', 'Going'], col2: ['I am', 'Tom'], } + const widthLimitedColumnsArray = getWidthLimitedColumnsArray( + columns, + row, + charLength + ); + + const totalLines = Object.values(widthLimitedColumnsArray).reduce( + (a, b) => Math.max(a, b.length), + 0 + ); + + const ret = []; + for ( + let currentLineIndex = 0; + currentLineIndex < totalLines; + currentLineIndex += 1 + ) { + const singleLine = renderOneLine( + tableStyle, + columns, + currentLineIndex, + widthLimitedColumnsArray, + isHeader, + row, + colorMap, + charLength + ); + + ret.push(singleLine); + } + + return ret; +}; + +const transformRow = (row: Row, columns: Column[]): Row => { + const transformedRow = JSON.parse(JSON.stringify(row)); + const transforms: Dictionary = {}; + columns + .filter((c) => { + return !!c.transform; + }) + .forEach((c) => { + transforms[c.name] = c.transform; + }); + Object.keys(transforms).forEach((t) => { + transformedRow.text[t] = transforms[t](transformedRow.text[t]); + }); + return transformedRow; +}; + +// ║ 1 ║ I would like some red wine please ║ 10.212 ║ +const renderRow = (table: TableInternal, row: Row): string[] => { + let ret: string[] = []; + const transformedRow = transformRow(row, table.columns); + ret = ret.concat( + renderWidthLimitedLines( + table.tableStyle, + table.columns, + transformedRow, + table.colorMap, + undefined, + table.charLength + ) + ); + return ret; +}; + +/* + The analysis Result + ╔═══════╦═══════════════════════════════════════╦════════╗ +*/ +const renderTableTitle = (table: TableInternal): string[] => { + const ret: string[] = []; + + if (table.title === undefined) { + return ret; + } + + const getTableWidth = () => { + const reducer = (accumulator: number, currentValue: number) => + // ║ cell ║, 2 spaces + cellTextSize + one border on the left + accumulator + currentValue + 2 + 1; + return table.columns + .map((m) => m.length || DEFAULT_COLUMN_LEN) + .reduce(reducer, 1); + }; + + const titleWithPadding = textWithPadding( + table.title as string, + DEFAULT_HEADER_ALIGNMENT, + getTableWidth() + ); + const styledText = new ColoredConsoleLine(table.colorMap); + styledText.addCharsWithColor(DEFAULT_HEADER_FONT_COLOR, titleWithPadding); + // The analysis Result + ret.push(styledText.renderConsole()); + return ret; +}; + +/* + ╔═══════╦═══════════════════════════════════════╦════════╗ + ║ index ║ text ║ value ║ + ╟═══════╬═══════════════════════════════════════╬════════╢ +*/ +const renderTableHeaders = (table: TableInternal): string[] => { + let ret: string[] = []; + + // ╔═══════╦═══════════════════════════════════════╦════════╗ + ret.push( + renderTableHorizontalBorders( + table.tableStyle.headerTop, + table.columns.map((m: Column) => m.length || DEFAULT_COLUMN_LEN) + ) + ); + + // ║ index ║ text ║ value ║ + const row = createHeaderAsRow(createRow, table.columns); + ret = ret.concat( + renderWidthLimitedLines( + table.tableStyle, + table.columns, + row, + table.colorMap, + true + ) + ); + + // ╟═══════╬═══════════════════════════════════════╬════════╢ + ret.push( + renderTableHorizontalBorders( + table.tableStyle.headerBottom, + table.columns.map((m) => m.length || DEFAULT_COLUMN_LEN) + ) + ); + + return ret; +}; + +const renderTableEnding = (table: TableInternal): string[] => { + const ret: string[] = []; + // ╚═══════╩═══════════════════════════════════════╩════════╝ + ret.push( + renderTableHorizontalBorders( + table.tableStyle.tableBottom, + table.columns.map((m) => m.length || DEFAULT_COLUMN_LEN) + ) + ); + return ret; +}; + +const renderRowSeparator = (table: TableInternal, row: Row): string[] => { + const ret: string[] = []; + const lastRowIndex = table.rows.length - 1; + const currentRowIndex = table.rows.indexOf(row); + + if (currentRowIndex !== lastRowIndex && row.separator) { + // ╟═══════╬═══════════════════════════════════════╬════════╢ + ret.push( + renderTableHorizontalBorders( + table.tableStyle.rowSeparator, + table.columns.map((m) => m.length || DEFAULT_COLUMN_LEN) + ) + ); + } + return ret; +}; + +export const renderTable = (table: TableInternal): string => { + preProcessColumns(table); // enable / disable cols, find maxLn of each col/ computed Columns + preProcessRows(table); // sort and filter + + const ret: string[] = []; + renderTableTitle(table).forEach((row) => ret.push(row)); + + renderTableHeaders(table).forEach((row) => ret.push(row)); + + table.rows.forEach((row) => { + renderRow(table, row).forEach((row_) => ret.push(row_)); + renderRowSeparator(table, row).forEach((row_) => ret.push(row_)); + }); + renderTableEnding(table).forEach((row) => ret.push(row)); + return ret.join('\n'); +}; + +export const renderSimpleTable = ( + rows: Dictionary[], + tableOptions?: ComplexOptions +) => { + const table = new TableInternal(tableOptions); + table.addRows(rows); + return renderTable(table); +}; + +export const printSimpleTable = ( + rows: Dictionary[], + tableOptions?: ComplexOptions +) => { + console.log(renderSimpleTable(rows, tableOptions)); +}; diff --git a/src/lib/utils/console-table-printer/src/internalTable/internal-table.ts b/src/lib/utils/console-table-printer/src/internalTable/internal-table.ts new file mode 100644 index 0000000..f397b03 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/internalTable/internal-table.ts @@ -0,0 +1,180 @@ +import { CharLengthDict, Dictionary, Row } from '../models/common.js'; +import { + ColumnOptionsRaw, + ComplexOptions, + ComputedColumn, + DefaultColumnOptions, + RowFilterFunction, + RowSortFunction, + Valuetransform, +} from '../models/external-table.js'; +import { Column, TableStyleDetails } from '../models/internal-table.js'; +import { ColorMap, DEFAULT_COLOR_MAP } from '../utils/colored-console-line.js'; +import { + DEFAULT_TABLE_STYLE, + DEFAULT_ROW_ALIGNMENT, + DEFAULT_ROW_FONT_COLOR, + DEFAULT_ROW_SEPARATOR, +} from '../utils/table-constants.js'; +import { + createColumFromOnlyName, + createRow, + RowOptions, +} from '../utils/table-helpers.js'; +import { rawColumnToInternalColumn } from './input-converter.js'; +import { renderTable } from './internal-table-printer.js'; + +const DEFAULT_ROW_SORT_FUNC = () => 0; + +const DEFAULT_ROW_FILTER_FUNC = () => true; + +class TableInternal { + title?: string; + + tableStyle: TableStyleDetails; + + columns: Column[]; + + rows: Row[]; + + filterFunction: RowFilterFunction; + + sortFunction: RowSortFunction; + + enabledColumns: string[]; + + disabledColumns: string[]; + + computedColumns: ComputedColumn[]; + + rowSeparator: boolean; + + colorMap: ColorMap; + + charLength: CharLengthDict; + + defaultColumnOptions?: DefaultColumnOptions; + + transforms?: Record; + + initSimple(columns: string[]) { + this.columns = columns.map((column) => ({ + name: column, + title: column, + alignment: DEFAULT_ROW_ALIGNMENT, + })); + } + + initDetailed(options: ComplexOptions) { + this.title = options?.title || this.title; + this.tableStyle = { + ...this.tableStyle, + ...options?.style, + }; + this.sortFunction = options?.sort || this.sortFunction; + this.filterFunction = options?.filter || this.filterFunction; + this.enabledColumns = options?.enabledColumns || this.enabledColumns; + this.disabledColumns = options?.disabledColumns || this.disabledColumns; + this.computedColumns = options?.computedColumns || this.computedColumns; + this.columns = + options?.columns?.map((column) => + rawColumnToInternalColumn(column, options?.defaultColumnOptions) + ) || this.columns; + this.rowSeparator = options?.rowSeparator || this.rowSeparator; + this.charLength = options?.charLength || this.charLength; + this.defaultColumnOptions = + options?.defaultColumnOptions || this.defaultColumnOptions; + + if (options?.shouldDisableColors) { + this.colorMap = {}; + } else if (options?.colorMap) { + this.colorMap = { ...this.colorMap, ...options.colorMap }; + } + + if (options.rows !== undefined) { + this.addRows(options.rows); + } + } + + constructor(options?: ComplexOptions | string[]) { + // default construction + this.rows = []; + this.columns = []; + this.title = undefined; + this.tableStyle = DEFAULT_TABLE_STYLE; + this.filterFunction = DEFAULT_ROW_FILTER_FUNC; + this.sortFunction = DEFAULT_ROW_SORT_FUNC; + this.enabledColumns = []; + this.disabledColumns = []; + this.computedColumns = []; + this.rowSeparator = DEFAULT_ROW_SEPARATOR; + this.colorMap = DEFAULT_COLOR_MAP; + this.charLength = {}; + this.defaultColumnOptions = undefined; + this.transforms = {}; + + if (options instanceof Array) { + this.initSimple(options); + } else if (typeof options === 'object') { + this.initDetailed(options); + } + } + + createColumnFromRow(text: Dictionary) { + const colNames = this.columns.map((col) => col.name); + Object.keys(text).forEach((key) => { + if (!colNames.includes(key)) { + this.columns.push( + rawColumnToInternalColumn( + createColumFromOnlyName(key), + this.defaultColumnOptions + ) + ); + } + }); + } + + addColumn(textOrObj: string | ComputedColumn | ColumnOptionsRaw) { + const columnOptionsFromInput = + typeof textOrObj === 'string' + ? createColumFromOnlyName(textOrObj) + : textOrObj; + this.columns.push( + rawColumnToInternalColumn( + columnOptionsFromInput, + this.defaultColumnOptions + ) + ); + } + + addColumns(toBeInsertedColumns: string[] | ColumnOptionsRaw[]) { + toBeInsertedColumns.forEach((toBeInsertedColumn) => { + this.addColumn(toBeInsertedColumn); + }); + } + + addRow(text: Dictionary, options?: RowOptions) { + this.createColumnFromRow(text); + this.rows.push( + createRow( + options?.color || DEFAULT_ROW_FONT_COLOR, + text, + options?.separator !== undefined + ? options?.separator + : this.rowSeparator + ) + ); + } + + addRows(toBeInsertedRows: Dictionary[], options?: RowOptions) { + toBeInsertedRows.forEach((toBeInsertedRow) => { + this.addRow(toBeInsertedRow, options); + }); + } + + renderTable() { + return renderTable(this); + } +} + +export default TableInternal; diff --git a/src/lib/utils/console-table-printer/src/internalTable/table-pre-processors.ts b/src/lib/utils/console-table-printer/src/internalTable/table-pre-processors.ts new file mode 100644 index 0000000..ad5be98 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/internalTable/table-pre-processors.ts @@ -0,0 +1,70 @@ +import { Row } from '../models/common.js'; +import { ComputedColumn } from '../models/external-table.js'; +import { Column } from '../models/internal-table.js'; +import { findLenOfColumn } from '../utils/table-helpers.js'; +import TableInternal from './internal-table.js'; + +// All these functions are ran when renderTable/printTable is called +const createComputedColumnsIfNecessary = (table: TableInternal) => { + if (table.computedColumns.length) { + table.computedColumns.forEach((computedColumn: ComputedColumn) => { + // This can happen if renderTable/printTable is called multiple times + const isColumnAlreadyExists = table.columns.some( + (col: Column) => col.name === computedColumn.name + ); + if (isColumnAlreadyExists) { + return; + } + table.addColumn({ + ...computedColumn, + ...table.defaultColumnOptions, + }); + table.rows.forEach((row: Row, index: number, rowsArray: Row[]) => { + const arrayRowText = rowsArray.map( + (elemInRowsArray) => elemInRowsArray.text + ); + row.text[computedColumn.name] = computedColumn.function( + row.text, + index, + arrayRowText + ); + }); + }); + } +}; + +const disableColumnsIfNecessary = (table: TableInternal) => { + if (table.enabledColumns.length) { + table.columns = table.columns.filter((col: Column) => + table.enabledColumns.includes(col.name) + ); + } +}; + +const enableColumnsIfNecessary = (table: TableInternal) => { + if (table.disabledColumns.length) { + table.columns = table.columns.filter( + (col: Column) => !table.disabledColumns.includes(col.name) + ); + } +}; + +const findColumnWidth = (table: TableInternal) => { + table.columns.forEach((column) => { + column.length = findLenOfColumn(column, table.rows, table.charLength); + }); +}; + +export const preProcessColumns = (table: TableInternal) => { + createComputedColumnsIfNecessary(table); + enableColumnsIfNecessary(table); + disableColumnsIfNecessary(table); + findColumnWidth(table); +}; + +export const preProcessRows = (table: TableInternal) => { + const newRows = table.rows + .filter((r) => table.filterFunction(r.text)) + .sort((r1, r2) => table.sortFunction(r1.text, r2.text)); + table.rows = newRows; +}; diff --git a/src/lib/utils/console-table-printer/src/models/common.ts b/src/lib/utils/console-table-printer/src/models/common.ts new file mode 100644 index 0000000..85df1e1 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/models/common.ts @@ -0,0 +1,17 @@ +import { ALIGNMENTS, COLORS } from '../utils/table-constants.js'; + +export type ALIGNMENT = (typeof ALIGNMENTS)[number]; + +export type COLOR = (typeof COLORS)[number]; +export interface Dictionary { + [key: string]: any; +} + +export interface CharLengthDict { + [key: string]: number; +} +export interface Row { + color: COLOR; + separator: boolean; + text: Dictionary; +} diff --git a/src/lib/utils/console-table-printer/src/models/external-table.ts b/src/lib/utils/console-table-printer/src/models/external-table.ts new file mode 100644 index 0000000..1d523ce --- /dev/null +++ b/src/lib/utils/console-table-printer/src/models/external-table.ts @@ -0,0 +1,110 @@ +import { ColorMap } from '../utils/colored-console-line.js'; +import { ALIGNMENT, CharLengthDict, COLOR, Dictionary } from './common.js'; +import { TableStyleDetails as InternalTableStyleDetails } from './internal-table.js'; + +export { ALIGNMENT, COLOR }; + +export type TableStyleDetails = Partial; + +// Type for table cell values - can be string, number, or undefined +export type CellValue = string | number | boolean | undefined | null; + +export type Valuetransform = (cellValue: CellValue) => CellValue; + +/** + * Configuration options for a table column + */ +export interface ColumnOptionsRaw { + /** Unique identifier for the column */ + name: string; + /** Display name for the column header. If not provided, uses the name property */ + title?: string; + /** Text alignment within the column: 'left', 'center', or 'right' */ + alignment?: ALIGNMENT; + /** Text color for the column content */ + color?: COLOR; + /** Maximum length of text in the column. Longer text will be wrapped to multiple lines */ + maxLen?: number; + /** Minimum length of text in the column. Shorter text will be padded with spaces */ + minLen?: number; + /** Value transform, For example 2.00000 => 2.0 */ + transform?: Valuetransform; +} + +/** + * Configuration for a computed column that generates values dynamically + */ +export interface ComputedColumn extends ColumnOptionsRaw { + /** Function that computes the column value for each row + * @param arg0 - The current row data object + * @param index - The index of the current row in the data array + * @param array - The complete array of row data + * @returns The computed value for this column + */ + function: (arg0: Dictionary, index: number, array: Dictionary[]) => CellValue; +} + +/** + * Function type for sorting table rows + * @param row1 - First row to compare + * @param row2 - Second row to compare + * @returns Negative number if row1 should come before row2, positive if row2 should come before row1, 0 if equal + */ +export type RowSortFunction = (row1: Dictionary, row2: Dictionary) => number; + +/** + * Function type for filtering table rows + * @param row - The row data to evaluate + * @returns True if the row should be included, false if it should be filtered out + */ +export type RowFilterFunction = (row: Dictionary) => boolean; + +/** + * Default styling options applied to all columns unless overridden + */ +export interface DefaultColumnOptions { + /** Default text alignment for all columns */ + alignment?: ALIGNMENT; + /** Default text color for all columns */ + color?: COLOR; + /** Default title prefix for all columns */ + title?: string; + /** Default maximum length for all columns */ + maxLen?: number; + /** Default minimum length for all columns */ + minLen?: number; +} + +/** + * Complete configuration options for table creation and styling + */ +export interface ComplexOptions { + /** Table styling configuration including borders and colors */ + style?: TableStyleDetails; + /** Title displayed at the top of the table */ + title?: string; + /** Array of column configurations */ + columns?: ColumnOptionsRaw[]; + /** Initial data rows for the table */ + rows?: Dictionary[]; + /** Function to sort rows before display */ + sort?: RowSortFunction; + /** Function to filter rows before display */ + filter?: RowFilterFunction; + /** Array of column names to include (all others will be hidden) */ + enabledColumns?: string[]; + /** Array of column names to exclude (these will always be hidden) */ + disabledColumns?: string[]; + /** Array of computed columns that generate values dynamically */ + computedColumns?: ComputedColumn[]; + /** Whether to add separator lines between rows */ + rowSeparator?: boolean; + /** Whether to disable color output (useful for non-color terminals) */ + shouldDisableColors?: boolean; + /** Custom color mapping for special characters or values */ + colorMap?: ColorMap; + /** Custom character length mapping for special characters (e.g., emojis) */ + charLength?: CharLengthDict; + /** Default options applied to all columns unless overridden */ + defaultColumnOptions?: DefaultColumnOptions; +} diff --git a/src/lib/utils/console-table-printer/src/models/internal-table.ts b/src/lib/utils/console-table-printer/src/models/internal-table.ts new file mode 100644 index 0000000..405cb93 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/models/internal-table.ts @@ -0,0 +1,33 @@ +import { ALIGNMENT, COLOR } from './common.js'; +import { Valuetransform } from './external-table.js'; + +/* +All the fields of Internal Table has to be mandatory +These fields are generated based on user input +and during generated is some input is missing it is filled by default value. +*/ + +export interface Column { + name: string; + title: string; + alignment?: ALIGNMENT; + color?: COLOR; + length?: number; + minLen?: number; + maxLen?: number; + transform?: Valuetransform; +} + +type TableLineDetailsKeys = 'left' | 'right' | 'mid' | 'other'; + +export type TableLineDetails = { + [key in TableLineDetailsKeys]: string; +}; + +export type TableStyleDetails = { + headerTop: TableLineDetails; + headerBottom: TableLineDetails; + tableBottom: TableLineDetails; + vertical: string; + rowSeparator: TableLineDetails; +}; diff --git a/src/lib/utils/console-table-printer/src/utils/colored-console-line.ts b/src/lib/utils/console-table-printer/src/utils/colored-console-line.ts new file mode 100644 index 0000000..9eb0d9c --- /dev/null +++ b/src/lib/utils/console-table-printer/src/utils/colored-console-line.ts @@ -0,0 +1,40 @@ +import { COLOR } from '../models/common.js'; + +export type ColorMap = { + [key in COLOR]?: string; +}; + +export const DEFAULT_COLOR_MAP: ColorMap = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + white_bold: '\x1b[01m', + reset: '\x1b[0m', +}; + +export default class ColoredConsoleLine { + text: string; + + colorMap: ColorMap; + + constructor(colorMap = DEFAULT_COLOR_MAP) { + this.text = ''; + this.colorMap = colorMap; + } + + addCharsWithColor(color: COLOR, text: string) { + const colorAnsi = this.colorMap[color]; + this.text += + colorAnsi !== undefined + ? `${colorAnsi}${text}${this.colorMap.reset}` + : text; + } + + renderConsole(): string { + return this.text; + } +} diff --git a/src/lib/utils/console-table-printer/src/utils/console-utils.ts b/src/lib/utils/console-table-printer/src/utils/console-utils.ts new file mode 100644 index 0000000..3c27586 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/utils/console-utils.ts @@ -0,0 +1,25 @@ +import { wcswidth } from '../../../simple-wcswidth/index.js'; +import { CharLengthDict } from '../models/common.js'; + +/* eslint-disable no-control-regex */ +const colorRegex = /\x1b\[\d{1,3}(;\d{1,3})*m/g; // \x1b[30m \x1b[305m \x1b[38;5m + +export const stripAnsi = (str: string): string => str.replace(colorRegex, ''); + +export const findWidthInConsole = ( + str: string, + charLength?: CharLengthDict +): number => { + let strLen = 0; + str = stripAnsi(str); + if (charLength) { + Object.entries(charLength).forEach(([key, value]) => { + // count appearance of the key in the string and remove from original string + const regex = new RegExp(key, 'g'); + strLen += (str.match(regex) || []).length * value; + str = str.replace(key, ''); + }); + } + strLen += wcswidth(str); + return strLen; +}; diff --git a/src/lib/utils/console-table-printer/src/utils/string-utils.ts b/src/lib/utils/console-table-printer/src/utils/string-utils.ts new file mode 100644 index 0000000..99a2573 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/utils/string-utils.ts @@ -0,0 +1,84 @@ +import { ALIGNMENT, CharLengthDict } from '../models/common.js'; +import { findWidthInConsole } from './console-utils.js'; + +// ("How are you?",10) => ["How are ", "you?"] +export const splitTextIntoTextsOfMinLen = ( + inpStr: string, + width: number, + charLength?: CharLengthDict +): string[] => { + const ret: string[] = []; + + const lines = inpStr.split(/[\n\r]/); + + lines.forEach((line) => { + const spaceSeparatedStrings = line.split(' '); + + let now: string[] = []; + let cnt = 0; + spaceSeparatedStrings.forEach((strWithoutSpace) => { + const consoleWidth = findWidthInConsole(strWithoutSpace, charLength); + if (cnt + consoleWidth <= width) { + cnt += consoleWidth + 1; // 1 for the space + now.push(strWithoutSpace); + } else { + if (now.length > 0) ret.push(now.join(' ')); + now = [strWithoutSpace]; + cnt = consoleWidth + 1; + } + }); + ret.push(now.join(' ')); + }); + return ret; +}; + +// ("How are you?",center, 20) => " How are you? " +// ("How are you?",right, 20) => " How are you?" +// ("How are you?",center, 4) => "How\nare\nyou?" +export const textWithPadding = ( + text: string, + alignment: ALIGNMENT, + columnLen: number, + charLength?: CharLengthDict +): string => { + const curTextSize = findWidthInConsole(text, charLength); + // alignments for center padding case + const leftPadding = Math.floor((columnLen - curTextSize) / 2); + const rightPadding = columnLen - leftPadding - curTextSize; + + // handle edge cases where the text size is larger than the column length + if (columnLen < curTextSize) { + const splittedLines = splitTextIntoTextsOfMinLen(text, columnLen); + if (splittedLines.length === 1) { + return text; + } + return splittedLines + .map((singleLine) => + textWithPadding(singleLine, alignment, columnLen, charLength) + ) + .join('\n'); + } + + // console.log(text, columnLen, curTextSize); + switch (alignment) { + case 'left': + return text.concat(' '.repeat(columnLen - curTextSize)); + case 'center': + return ' ' + .repeat(leftPadding) + .concat(text) + .concat(' '.repeat(rightPadding)); + case 'right': + default: + return ' '.repeat(columnLen - curTextSize).concat(text); + } +}; + +// ("How are you?",10) => ["How are ", "you?"] +export const biggestWordInSentence = ( + inpStr: string, + charLength?: CharLengthDict +): number => + inpStr + .split(' ') + .reduce((a, b) => Math.max(a, findWidthInConsole(b, charLength)), 0); diff --git a/src/lib/utils/console-table-printer/src/utils/table-constants.ts b/src/lib/utils/console-table-printer/src/utils/table-constants.ts new file mode 100644 index 0000000..60e99b3 --- /dev/null +++ b/src/lib/utils/console-table-printer/src/utils/table-constants.ts @@ -0,0 +1,61 @@ +import { ALIGNMENT, COLOR } from '../models/common.js'; +import { TableStyleDetails } from '../models/internal-table.js'; + +export const DEFAULT_COLUMN_LEN = 20; + +export const DEFAULT_ROW_SEPARATOR = false; + +export const DEFAULT_TABLE_STYLE: TableStyleDetails = { + /* + Default Style + ┌────────────┬─────┬──────┐ + │ foo │ bar │ baz │ + │ frobnicate │ bar │ quuz │ + └────────────┴─────┴──────┘ + */ + headerTop: { + left: '┌', + mid: '┬', + right: '┐', + other: '─', + }, + headerBottom: { + left: '├', + mid: '┼', + right: '┤', + other: '─', + }, + tableBottom: { + left: '└', + mid: '┴', + right: '┘', + other: '─', + }, + vertical: '│', + rowSeparator: { + left: '├', + mid: '┼', + right: '┤', + other: '─', + }, +}; + +export const ALIGNMENTS = ['right', 'left', 'center']; + +export const COLORS = [ + 'red', + 'green', + 'yellow', + 'white', + 'blue', + 'magenta', + 'cyan', + 'white_bold', + 'reset', +]; + +export const DEFAULT_ROW_FONT_COLOR: COLOR = 'white'; +export const DEFAULT_HEADER_FONT_COLOR: COLOR = 'white_bold'; + +export const DEFAULT_ROW_ALIGNMENT: ALIGNMENT = 'right'; +export const DEFAULT_HEADER_ALIGNMENT: ALIGNMENT = 'center'; diff --git a/src/lib/utils/console-table-printer/src/utils/table-helpers.ts b/src/lib/utils/console-table-printer/src/utils/table-helpers.ts new file mode 100644 index 0000000..f6702be --- /dev/null +++ b/src/lib/utils/console-table-printer/src/utils/table-helpers.ts @@ -0,0 +1,184 @@ +import { CharLengthDict, COLOR, Dictionary, Row } from '../models/common.js'; +import { CellValue, Valuetransform } from '../models/external-table.js'; +import { Column, TableLineDetails } from '../models/internal-table.js'; +import { findWidthInConsole } from './console-utils.js'; +import { + biggestWordInSentence, + splitTextIntoTextsOfMinLen, +} from './string-utils.js'; +import { + DEFAULT_COLUMN_LEN, + DEFAULT_HEADER_FONT_COLOR, + DEFAULT_ROW_SEPARATOR, +} from './table-constants.js'; + +const max = (a: number, b: number) => Math.max(a, b); + +// takes any input that is given by user and converts to string +export const cellText = (text: CellValue): string => + text === undefined || text === null ? '' : `${text}`; + +// evaluate cell text with defined transform +export const evaluateCellText = ( + text: CellValue, + transform?: Valuetransform +): string => (transform ? `${transform(text)}` : cellText(text)); + +export interface RowOptionsRaw { + color?: string; + separator?: boolean; +} + +export interface RowOptions { + color: COLOR; + separator: boolean; +} + +export interface CreateRowFunction { + (color: COLOR, text: Dictionary, separator: boolean): Row; +} + +export const convertRawRowOptionsToStandard = ( + options?: RowOptionsRaw +): RowOptions | undefined => { + if (options) { + return { + color: options.color as COLOR, + separator: options.separator || DEFAULT_ROW_SEPARATOR, + }; + } + return undefined; +}; + +// ({ left: "╚", mid: "╩", right: "╝", other: "═" }, [5, 10, 7]) => "╚═══════╩════════════╩═════════╝" +export const createTableHorizontalBorders = ( + { left, mid, right, other }: TableLineDetails, + column_lengths: number[] +) => { + // ╚ + let ret = left; + + // ╚═══════╩═══════════════════════════════════════╩════════╩ + column_lengths.forEach((len) => { + ret += other.repeat(len + 2); + ret += mid; + }); + + // ╚═══════╩═══════════════════════════════════════╩════════ + ret = ret.slice(0, -mid.length); + + // ╚═══════╩═══════════════════════════════════════╩════════╝ + ret += right; + return ret; +}; + +// ("id") => { name: "id", title: "id" } +export const createColumFromOnlyName = ( + name: string +): { name: string; title: string } => ({ + name, + title: name, +}); + +// ("green", { id: 1, name: "John" }, true) => { color: "green", separator: true, text: { id: 1, name: "John" } } +export const createRow: CreateRowFunction = ( + color: COLOR, + text: Dictionary, + separator: boolean +): Row => ({ + color, + separator, + text, +}); + +// ({ name: "id", title: "ID", minLen: 2 }, [{ text: { id: 1 } }, { text: { id: 100 } }]) => 3 +// Calculates optimal column width based on content and constraints +export const findLenOfColumn = ( + column: Column, + rows: Row[], + charLength?: CharLengthDict +): number => { + const columnId = column.name; + const columnTitle = column.title; + const datatransform = column.transform; + let length = max(0, column?.minLen || 0); + + if (column.maxLen) { + // if customer input is mentioned a max width, lets see if all other can fit here + // if others cant fit find the max word length so that at least the table can be printed + length = max( + length, + max(column.maxLen, biggestWordInSentence(columnTitle, charLength)) + ); + length = rows.reduce( + (acc, row) => + max( + acc, + biggestWordInSentence( + evaluateCellText(row.text[columnId], datatransform), + charLength + ) + ), + length + ); + return length; + } + + length = max(length, findWidthInConsole(columnTitle, charLength)); + + rows.forEach((row) => { + length = max( + length, + findWidthInConsole( + evaluateCellText(row.text[columnId], datatransform), + charLength + ) + ); + }); + + return length; +}; + +// ({ left: "╚", mid: "╩", right: "╝", other: "═" }, [5, 10, 7]) => "╚═══════╩════════════╩═════════╝" +// (undefined, [5, 10, 7]) => "" +export const renderTableHorizontalBorders = ( + style: TableLineDetails, + column_lengths: number[] +): string => { + const str = createTableHorizontalBorders(style, column_lengths); + return str; +}; + +// (createRow, [{ name: "id", title: "ID" }, { name: "name", title: "Name" }]) => +// { color: "white_bold", separator: false, text: { id: "ID", name: "Name" } } +export const createHeaderAsRow = ( + createRowFn: CreateRowFunction, + columns: Column[] +): Row => { + const headerColor: COLOR = DEFAULT_HEADER_FONT_COLOR; + const row: Row = createRowFn(headerColor, {}, false); + columns.forEach((column) => { + row.text[column.name] = column.title; + }); + return row; +}; + +// ([{ name: "desc", length: 10 }], { text: { desc: "This is a long description" } }) +// => { desc: ["This is a", "long", "description"] } +export const getWidthLimitedColumnsArray = ( + columns: Column[], + row: Row, + charLength?: CharLengthDict +): { [key: string]: string[] } => { + const ret: { [key: string]: string[] } = {}; + + columns.forEach((column) => { + ret[column.name] = splitTextIntoTextsOfMinLen( + cellText(row.text[column.name]), + column.length || DEFAULT_COLUMN_LEN, + charLength + ); + }); + + return ret; +}; diff --git a/src/lib/utils/context_vars.ts b/src/lib/utils/context_vars.ts new file mode 100644 index 0000000..e5d37ae --- /dev/null +++ b/src/lib/utils/context_vars.ts @@ -0,0 +1,34 @@ +import { _LC_CONTEXT_VARIABLES_KEY } from "../singletons/constants.js"; + +/** + * Get a context variable from a run tree instance + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function getContextVar(runTree: any, key: symbol): unknown { + if (_LC_CONTEXT_VARIABLES_KEY in runTree) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contextVars = (runTree as any)[_LC_CONTEXT_VARIABLES_KEY]; + return contextVars[key]; + } + return undefined; +} + +/** + * Set a context variable on a run tree instance + */ +export function setContextVar( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + runTree: any, + key: symbol, + value: unknown, +): void { + const contextVars = + _LC_CONTEXT_VARIABLES_KEY in runTree + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (runTree as any)[_LC_CONTEXT_VARIABLES_KEY] + : {}; + + contextVars[key] = value; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (runTree as any)[_LC_CONTEXT_VARIABLES_KEY] = contextVars; +} diff --git a/src/lib/utils/env.ts b/src/lib/utils/env.ts new file mode 100644 index 0000000..c445a6b --- /dev/null +++ b/src/lib/utils/env.ts @@ -0,0 +1,298 @@ +// Inlined from https://github.com/flexdinesh/browser-or-node +import { __version__ } from "../index.js"; +let globalEnv: string; + +export const isBrowser = () => + typeof window !== "undefined" && typeof window.document !== "undefined"; + +export const isWebWorker = () => + typeof globalThis === "object" && + globalThis.constructor && + globalThis.constructor.name === "DedicatedWorkerGlobalScope"; + +export const isJsDom = () => + (typeof window !== "undefined" && window.name === "nodejs") || + (typeof navigator !== "undefined" && navigator.userAgent.includes("jsdom")); + +// Supabase Edge Function provides a `Deno` global object +// without `version` property +export const isDeno = () => + typeof (globalThis as { Deno?: unknown }).Deno !== "undefined"; + +// Mark not-as-node if in Supabase Edge Function +export const isNode = () => + typeof process !== "undefined" && + typeof process.versions !== "undefined" && + typeof process.versions.node !== "undefined" && + !isDeno(); + +export const getEnv = () => { + if (globalEnv) { + return globalEnv; + } + // @ts-expect-error Bun types are not imported due to conflicts with Node types + if (typeof Bun !== "undefined") { + globalEnv = "bun"; + } else if (isBrowser()) { + globalEnv = "browser"; + } else if (isNode()) { + globalEnv = "node"; + } else if (isWebWorker()) { + globalEnv = "webworker"; + } else if (isJsDom()) { + globalEnv = "jsdom"; + } else if (isDeno()) { + globalEnv = "deno"; + } else { + globalEnv = "other"; + } + + return globalEnv; +}; + +export type RuntimeEnvironment = { + library: string; + libraryVersion?: string; + sdk: string; + sdk_version: string; + runtime: string; + runtimeVersion?: string; +}; + +let runtimeEnvironment: RuntimeEnvironment | undefined; + +export function getRuntimeEnvironment(): RuntimeEnvironment { + if (runtimeEnvironment === undefined) { + const env = getEnv(); + const releaseEnv = getShas(); + runtimeEnvironment = { + library: "langsmith", + runtime: env, + sdk: "langsmith-js", + sdk_version: __version__, + ...releaseEnv, + }; + } + return runtimeEnvironment; +} + +// Substrings marking a variable as secret-bearing or personal; matched +// case-insensitively against the variable name. +const EXCLUDED_SUBSTRINGS = [ + "key", + "secret", + "token", + "password", + "passwd", + "pwd", + "credential", + "email", +]; + +function isSensitiveEnvVarName(key: string): boolean { + const lowered = key.toLowerCase(); + return EXCLUDED_SUBSTRINGS.some((sub) => lowered.includes(sub)); +} + +/** + * Retrieves the LangSmith-specific metadata from the current runtime environment. + * + * @returns {Record} + * - A record of LangSmith-specific metadata environment variables. + */ +export function getLangSmithEnvVarsMetadata(): Record { + const allEnvVars = getLangSmithEnvironmentVariables(); + const envVars: Record = {}; + const excluded = [ + "LANGCHAIN_API_KEY", + "LANGCHAIN_ENDPOINT", + "LANGCHAIN_TRACING_V2", + "LANGCHAIN_PROJECT", + "LANGCHAIN_SESSION", + "LANGSMITH_API_KEY", + "LANGSMITH_ENDPOINT", + "LANGSMITH_TRACING_V2", + "LANGSMITH_CONFIG_FILE", + "LANGSMITH_PROJECT", + "LANGSMITH_SESSION", + ]; + + for (const [key, value] of Object.entries(allEnvVars)) { + if ( + typeof value === "string" && + !excluded.includes(key) && + !isSensitiveEnvVarName(key) + ) { + if (key === "LANGCHAIN_REVISION_ID") { + envVars["revision_id"] = value; + } else { + envVars[key] = value; + } + } + } + + return envVars; +} + +/** + * Retrieves only the LangChain/LangSmith-prefixed environment variables from the current runtime environment. + * This is more efficient than copying all environment variables. + * + * @returns {Record} + * - A record of LangChain/LangSmith environment variables. + */ +export function getLangSmithEnvironmentVariables(): Record { + const envVars: Record = {}; + + try { + // Check for Node.js environment + // eslint-disable-next-line no-process-env + if (typeof process !== "undefined" && process.env) { + // eslint-disable-next-line no-process-env + for (const [key, value] of Object.entries(process.env)) { + if ( + (key.startsWith("LANGCHAIN_") || key.startsWith("LANGSMITH_")) && + value != null + ) { + if (isSensitiveEnvVarName(key) && typeof value === "string") { + envVars[key] = + value.slice(0, 2) + + "*".repeat(value.length - 4) + + value.slice(-2); + } else { + envVars[key] = value; + } + } + } + } + } catch (_e) { + // Catch any errors that might occur while trying to access environment variables + } + + return envVars; +} + +export function getEnvironmentVariable(name: string): string | undefined { + // Certain Deno setups will throw an error if you try to access environment variables + // https://github.com/hwchase17/langchainjs/issues/1412 + try { + return typeof process !== "undefined" + ? // eslint-disable-next-line no-process-env + process.env?.[name] + : undefined; + } catch (_e) { + return undefined; + } +} + +export function getLangSmithEnvironmentVariable( + name: string, +): string | undefined { + return ( + getEnvironmentVariable(`LANGSMITH_${name}`) || + getEnvironmentVariable(`LANGCHAIN_${name}`) + ); +} + +export function setEnvironmentVariable(name: string, value: string): void { + if (typeof process !== "undefined") { + // eslint-disable-next-line no-process-env + process.env[name] = value; + } +} + +interface ICommitSHAs { + [key: string]: string; +} +let cachedCommitSHAs: ICommitSHAs | undefined; +/** + * Get the Git commit SHA from common environment variables + * used by different CI/CD platforms. + * @returns {string | undefined} The Git commit SHA or undefined if not found. + */ +export function getShas(): ICommitSHAs { + if (cachedCommitSHAs !== undefined) { + return cachedCommitSHAs; + } + const common_release_envs = [ + "VERCEL_GIT_COMMIT_SHA", + "NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA", + "COMMIT_REF", + "RENDER_GIT_COMMIT", + "CI_COMMIT_SHA", + "CIRCLE_SHA1", + "CF_PAGES_COMMIT_SHA", + "REACT_APP_GIT_SHA", + "SOURCE_VERSION", + "GITHUB_SHA", + "TRAVIS_COMMIT", + "GIT_COMMIT", + "BUILD_VCS_NUMBER", + "bamboo_planRepository_revision", + "Build.SourceVersion", + "BITBUCKET_COMMIT", + "DRONE_COMMIT_SHA", + "SEMAPHORE_GIT_SHA", + "BUILDKITE_COMMIT", + ] as const; + + const shas: ICommitSHAs = {}; + for (const env of common_release_envs) { + const envVar = getEnvironmentVariable(env); + if (envVar !== undefined) { + shas[env] = envVar; + } + } + cachedCommitSHAs = shas; + return shas; +} + +export function getOtelEnabled(): boolean { + return ( + getEnvironmentVariable("OTEL_ENABLED") === "true" || + getLangSmithEnvironmentVariable("OTEL_ENABLED") === "true" + ); +} + +export type TracingMode = "langsmith" | "otel"; + +const _VALID_TRACING_MODES: Set = new Set(["langsmith", "otel"]); + +/** + * Resolve the effective tracing mode from an explicit config value and + * environment variables. + * + * Priority: explicit argument > `LANGSMITH_TRACING_MODE` env var > + * legacy `OTEL_ENABLED` / `LANGSMITH_OTEL_ENABLED` env vars > `"langsmith"`. + */ +export function resolveTracingMode(configValue?: TracingMode): TracingMode { + if (configValue !== undefined) { + return configValue; + } + + const envMode = getLangSmithEnvironmentVariable("TRACING_MODE"); + + if (envMode !== undefined && envMode !== "") { + const lower = envMode.toLowerCase(); + if (!_VALID_TRACING_MODES.has(lower)) { + throw new Error( + `Invalid LANGSMITH_TRACING_MODE=${JSON.stringify(envMode)}. ` + + `Must be one of: ${[..._VALID_TRACING_MODES].sort().join(", ")}`, + ); + } + if (getOtelEnabled()) { + console.warn( + "Both LANGSMITH_TRACING_MODE and the legacy OTEL_ENABLED / " + + "LANGSMITH_OTEL_ENABLED env vars are set. " + + "LANGSMITH_TRACING_MODE takes precedence.", + ); + } + return lower as TracingMode; + } + + // Fall back to legacy env var + if (getOtelEnabled()) { + return "otel"; + } + return "langsmith"; +} diff --git a/src/lib/utils/error.ts b/src/lib/utils/error.ts new file mode 100644 index 0000000..f78318f --- /dev/null +++ b/src/lib/utils/error.ts @@ -0,0 +1,205 @@ +/** + * Get the error message for an invalid prompt identifier. + * Used consistently across the codebase when parsing prompt identifiers fails. + * + * @param identifier - The invalid identifier that was provided + * @returns A formatted error message explaining the valid formats + */ +export function getInvalidPromptIdentifierMsg(identifier: string): string { + return ( + `Invalid prompt identifier format: "${identifier}". ` + + `Expected one of:\n` + + ` - "prompt-name" (for private prompts)\n` + + ` - "owner/prompt-name" (for prompts with explicit owner)\n` + + ` - "prompt-name:commit-hash" (with commit reference)\n` + + ` - "owner/prompt-name:commit-hash" (with owner and commit)` + ); +} + +function getErrorStackTrace(e: unknown) { + if (typeof e !== "object" || e == null) return undefined; + if (!("stack" in e) || typeof e.stack !== "string") return undefined; + + let stack = e.stack; + + const prevLine = `${e}`; + if (stack.startsWith(prevLine)) { + stack = stack.slice(prevLine.length); + } + + if (stack.startsWith("\n")) { + stack = stack.slice(1); + } + + return stack; +} + +export function printErrorStackTrace(e: unknown) { + const stack = getErrorStackTrace(e); + if (stack == null) return; + console.error(stack); +} + +/** + * LangSmithConflictError + * + * Represents an error that occurs when there's a conflict during an operation, + * typically corresponding to HTTP 409 status code responses. + * + * This error is thrown when an attempt to create or modify a resource conflicts + * with the current state of the resource on the server. Common scenarios include: + * - Attempting to create a resource that already exists + * - Trying to update a resource that has been modified by another process + * - Violating a uniqueness constraint in the data + * + * @extends Error + * + * @example + * try { + * await createProject("existingProject"); + * } catch (error) { + * if (error instanceof ConflictError) { + * console.log("A conflict occurred:", error.message); + * // Handle the conflict, e.g., by suggesting a different project name + * } else { + * // Handle other types of errors + * } + * } + * + * @property {string} name - Always set to 'ConflictError' for easy identification + * @property {string} message - Detailed error message including server response + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/409 + */ +export class LangSmithConflictError extends Error { + status: number; + + constructor(message: string) { + super(message); + this.name = "LangSmithConflictError"; + this.status = 409; + } +} + +/** + * LangSmithNotFoundError + * + * Represents an error that occurs when a requested resource is not found, + * typically corresponding to HTTP 404 status code responses. + * + * @extends Error + */ +export class LangSmithNotFoundError extends Error { + status: number; + + constructor(message: string) { + super(message); + this.name = "LangSmithNotFoundError"; + this.status = 404; + } +} + +export function isLangSmithNotFoundError( + error: unknown, +): error is LangSmithNotFoundError { + return ( + error != null && + typeof error === "object" && + "name" in error && + error?.name === "LangSmithNotFoundError" + ); +} + +export function isLangSmithConflictError( + error: unknown, +): error is LangSmithConflictError { + return ( + error != null && + typeof error === "object" && + "name" in error && + error?.name === "LangSmithConflictError" + ); +} + +/** + * Throws an appropriate error based on the response status and body. + * + * @param response - The fetch Response object + * @param context - Additional context to include in the error message (e.g., operation being performed) + * @throws {LangSmithConflictError} When the response status is 409 + * @throws {Error} For all other non-ok responses + */ +export async function raiseForStatus( + response: Response, + context: string, + consumeOnSuccess?: boolean, +): Promise { + let errorBody; + if (response.ok) { + // consume the response body to release the connection + // https://undici.nodejs.org/#/?id=garbage-collection + if (consumeOnSuccess) { + errorBody = await response.text(); + } + return; + } + + try { + errorBody = await response.text(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (_e: any) { + errorBody = ""; + } + + if (response.status === 403) { + let errorData; + try { + errorData = JSON.parse(errorBody); + } catch { + // Non-JSON body (e.g. a proxy in front of a self-hosted instance): + // fall through and surface the raw body like every other status. + } + if (errorData?.error === "org_scoped_key_requires_workspace") { + errorBody = + "This API key is org-scoped and requires workspace specification. " + + "Please provide 'workspaceId' parameter, " + + "or set LANGSMITH_WORKSPACE_ID environment variable."; + } + } + + const fullMessage = `Failed to ${context}. Received status [${response.status}]: ${response.statusText}. Message: ${errorBody}`; + + if (response.status === 404) { + throw new LangSmithNotFoundError(fullMessage); + } + + if (response.status === 409) { + throw new LangSmithConflictError(fullMessage); + } + + const err = new Error(fullMessage); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (err as any).status = response.status; + throw err; +} + +const ERR_CONFLICTING_ENDPOINTS = "ERR_CONFLICTING_ENDPOINTS"; +export class ConflictingEndpointsError extends Error { + readonly code = ERR_CONFLICTING_ENDPOINTS; + constructor() { + super( + "You cannot provide both LANGSMITH_ENDPOINT / LANGCHAIN_ENDPOINT " + + "and LANGSMITH_RUNS_ENDPOINTS.", + ); + this.name = "ConflictingEndpointsError"; // helpful in logs + } +} +export function isConflictingEndpointsError( + err: unknown, +): err is ConflictingEndpointsError { + return ( + typeof err === "object" && + err !== null && + (err as Record).code === ERR_CONFLICTING_ENDPOINTS + ); +} diff --git a/src/lib/utils/fast-safe-stringify/LICENSE b/src/lib/utils/fast-safe-stringify/LICENSE new file mode 100644 index 0000000..bec900d --- /dev/null +++ b/src/lib/utils/fast-safe-stringify/LICENSE @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) 2016 David Mark Clements +Copyright (c) 2017 David Mark Clements & Matteo Collina +Copyright (c) 2018 David Mark Clements, Matteo Collina & Ruben Bridgewater + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/lib/utils/fast-safe-stringify/index.ts b/src/lib/utils/fast-safe-stringify/index.ts new file mode 100644 index 0000000..6796128 --- /dev/null +++ b/src/lib/utils/fast-safe-stringify/index.ts @@ -0,0 +1,556 @@ +/* eslint-disable */ +// @ts-nocheck +import { getLangSmithEnvironmentVariable } from "../../utils/env.js"; + +var LIMIT_REPLACE_NODE = "[...]"; +var CIRCULAR_REPLACE_NODE = { result: "[Circular]" }; + +var arr = []; +var replacerStack = []; + +const encoder = new TextEncoder(); + +function defaultOptions() { + return { + depthLimit: Number.MAX_SAFE_INTEGER, + edgesLimit: Number.MAX_SAFE_INTEGER, + }; +} + +function encodeString(str: string): Uint8Array { + return encoder.encode(str); +} + +// Shared function to handle well-known types +function serializeWellKnownTypes(val) { + if (val && typeof val === "object" && val !== null) { + if (val instanceof Map) { + return Object.fromEntries(val); + } else if (val instanceof Set) { + return Array.from(val); + } else if (val instanceof Date) { + return val.toISOString(); + } else if (val instanceof RegExp) { + return val.toString(); + } else if (val instanceof Error) { + return { + name: val.name, + message: val.message, + }; + } + } else if (typeof val === "bigint") { + return val.toString(); + } + return val; +} + +// Default replacer function to handle well-known types +function createDefaultReplacer(userReplacer?) { + return function (key, val) { + // Apply user replacer first if provided + if (userReplacer) { + const userResult = userReplacer.call(this, key, val); + // If user replacer returned undefined, fall back to our serialization + if (userResult !== undefined) { + return userResult; + } + } + + // Fall back to our well-known type handling + return serializeWellKnownTypes(val); + }; +} + +/** + * Estimate the serialized JSON byte size of a value without actually + * serializing it. Used on hot paths (enqueuing runs for batched tracing) + * where the exact serialized size is not required -- only a reasonable + * approximation for soft memory accounting. + * + * Walks the object graph in O(n) without allocating a JSON string, + * avoiding the event-loop blocking that JSON.stringify causes on large + * payloads. + * + * Accuracy notes (all estimates are approximate, never exact): + * - Strings: UTF-8 byte length via Buffer.byteLength when available, + * falling back to code-unit length for non-Node environments. Does + * not account for escape expansion (\", \\, control chars, surrogate + * escapes) which is usually a small fraction of total size. + * - Binary data (Buffer / typed arrays / ArrayBuffer / DataView): sized + * from their JSON.stringify representations where practical + * ({ type: "Buffer", data: [...] } for Buffer, keyed objects for typed + * arrays). DataView and ArrayBuffer themselves have no enumerable own + * properties and serialize as "{}". Each byte contributes ~3.5 + * characters on average in Buffer's decimal-array representation + * (digit(s) + comma). + * - Other objects with toJSON(): we invoke toJSON() once and estimate + * the result. This matches JSON.stringify semantics for libraries + * like Decimal.js, Moment, Luxon, Mongoose documents, etc. + * - Cycles: detected via an ancestor-path set that is pushed/popped + * during recursion. This matches JSON.stringify semantics -- + * repeated references that are *not* on the current ancestor chain + * (shared subobjects) are counted every time they appear, because + * JSON.stringify will serialize them every time. + * - No depth limit (JSON.stringify has none either). + */ +export interface EstimatedSize { + /** Approximate serialized JSON byte size. */ + size: number; + /** + * Length (in UTF-8 bytes) of the longest single string value encountered + * anywhere in the payload graph. Callers can use this as a shape-aware + * dispatch signal -- for example, to decide whether to offload serialize + * to a worker thread (which only pays off when a payload contains one + * or more large strings, since V8 shares string storage across isolates + * via refcount). + */ + maxStringLen: number; +} + +export function estimateSerializedSize(value: unknown): EstimatedSize { + try { + // Ancestor set for cycle detection. An object is only treated as + // circular if it appears on the current recursion path, not merely + // if it has been seen before elsewhere in the graph. + const ancestors = new Set(); + let maxStringLen = 0; + + // In Node / Bun, Buffer.byteLength is a fast native way to get UTF-8 + // byte length without allocating an encoded copy. In other runtimes + // we fall back to code-unit length (a small under-estimate for + // non-ASCII text, which is acceptable for soft limits). + const byteLen: (s: string) => number = + typeof Buffer !== "undefined" && typeof Buffer.byteLength === "function" + ? (s) => Buffer.byteLength(s, "utf8") + : (s) => s.length; + + function estimateString(s: string): number { + // +2 for the surrounding quotes. Escape expansion is not counted. + const n = byteLen(s); + if (n > maxStringLen) maxStringLen = n; + return n + 2; + } + + // Size of a byte sequence when rendered as a JSON array of decimal + // numbers: "[b0,b1,b2,...]". Each byte averages ~3.5 chars (value 0-9 + // => 1 char, 10-99 => 2 chars, 100-255 => 3 chars; weighted mean over + // a uniform distribution is ~2.81, plus one comma per element except + // the last). Round up to 4 bytes/element for a small safety margin. + function estimateByteArrayJson(byteLength: number): number { + if (byteLength === 0) return 2; // "[]" + return 2 + byteLength * 4; + } + + // Returns true for values that JSON.stringify drops when they appear + // as an object property (as opposed to an array element, where they + // become "null"). + function isDropped(v: unknown): boolean { + return ( + v === undefined || typeof v === "function" || typeof v === "symbol" + ); + } + + // In arrays, undefined / function / symbol become "null" (4 bytes). + function estimateInArray(v: unknown): number { + if (v === undefined || typeof v === "function" || typeof v === "symbol") { + return 4; + } + return estimate(v); + } + + function estimate(val: unknown): number { + if (val === null) return 4; // "null" + if (val === undefined) return 0; // top-level or property context; array handled separately + const t = typeof val; + if (t === "boolean") return 5; // "true" / "false" upper bound + if (t === "number") { + if (!Number.isFinite(val as number)) return 4; // "null" + // Convert to string to get exact length. This is cheap for numbers + // (V8 caches small-number strings) and makes the estimate far + // tighter for common cases like integer arrays. + return (val as number).toString().length; + } + if (t === "bigint") { + // Our replacer renders BigInt via .toString(), then JSON.stringify + // quotes it. + return (val as bigint).toString().length + 2; + } + if (t === "string") return estimateString(val as string); + if (t === "function" || t === "symbol") return 0; + + // Objects from here on. + const obj = val as object; + + // Well-known types handled by our replacer. + if (obj instanceof Date) return 26; // "2024-01-01T00:00:00.000Z" + if (obj instanceof RegExp) return byteLen(obj.toString()) + 2; + if (obj instanceof Error) { + const name = obj.name ?? ""; + const message = obj.message ?? ""; + // {"name":"...","message":"..."} + return 22 + byteLen(name) + byteLen(message); + } + + // Binary data types. These commonly appear in LLM payloads (images, + // audio) and their JSON representations vary widely. + if (typeof Buffer !== "undefined" && obj instanceof Buffer) { + // { "type": "Buffer", "data": [0, 1, 2, ...] } + return 28 + estimateByteArrayJson(obj.byteLength); + } + if (ArrayBuffer.isView(obj)) { + if (obj instanceof DataView) { + // DataView has no enumerable own properties; serializes as "{}". + return 2; + } + // Typed arrays serialize as {"0":v0,"1":v1,...} (keyed objects), + // which is much larger than a plain array would be. Per element + // cost: digits for index + digits for value + ":" + "," + quotes. + const len = (obj as unknown as { length: number }).length ?? 0; + const isFloat = + obj instanceof Float32Array || obj instanceof Float64Array; + // Index digits grow with len; worst-case per element: + // "NNN":V, where NNN = log10(len) digits and V depends on type. + // Loose but safe bounds: integer views ~12 chars/element, float + // views ~30 chars/element (Float32 ToString can be up to ~17 chars). + const perElement = isFloat ? 30 : 12; + return 2 + len * perElement; + } + if (obj instanceof ArrayBuffer) { + // Plain ArrayBuffer has no enumerable properties; serializes as "{}". + return 2; + } + + if (ancestors.has(obj)) { + // Cycle: our decirc fallback replaces with { result: "[Circular]" }. + return 24; + } + + // Custom toJSON (Decimal.js, Moment, Luxon, Mongoose docs, etc.). + // This runs after explicit built-in / binary cases above so known + // types (for example Buffer) use their dedicated fast-path sizing + // logic instead of duck-typing through toJSON(). + if (typeof (obj as { toJSON?: unknown }).toJSON === "function") { + let projected: unknown; + try { + projected = (obj as { toJSON: (key?: string) => unknown }).toJSON(""); + } catch { + // If toJSON throws, JSON.stringify would also throw and our + // serializer would emit "[Unserializable]" (~16 bytes). + return 16; + } + ancestors.add(obj); + const size = estimate(projected); + ancestors.delete(obj); + return size; + } + + ancestors.add(obj); + let size: number; + if (Array.isArray(obj)) { + size = 2; // [] + const len = obj.length; + for (let i = 0; i < len; i++) { + size += estimateInArray(obj[i]); + if (i < len - 1) size += 1; // comma + } + } else if (obj instanceof Map) { + // Rendered as { k: v, ... } via Object.fromEntries. + size = 2; + let emitted = 0; + for (const [k, v] of obj) { + if (isDropped(v)) continue; + if (emitted > 0) size += 1; // comma + const keyStr = typeof k === "string" ? k : String(k); + size += byteLen(keyStr) + 3; // "key": + size += estimate(v); + emitted++; + } + } else if (obj instanceof Set) { + // Rendered as [v, ...] via Array.from. + size = 2; + let emitted = 0; + for (const v of obj) { + if (emitted > 0) size += 1; // comma + size += estimateInArray(v); + emitted++; + } + } else { + size = 2; // {} + let emitted = 0; + // Object.keys only returns own enumerable string keys, matching + // JSON.stringify. + const keys = Object.keys(obj); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const v = (obj as Record)[key]; + if (isDropped(v)) continue; + if (emitted > 0) size += 1; // comma + size += byteLen(key) + 3; // "key": + size += estimate(v); + emitted++; + } + } + ancestors.delete(obj); + return size; + } + + const size = estimate(value); + return { size, maxStringLen }; + } catch { + // If the estimator itself hits an unexpected edge case, fall back to the + // exact serialized size. This preserves correctness of queue-size + // accounting at the cost of a slower hot path for that one payload. + // We cannot cheaply recover maxStringLen here, so report 0: the worker + // gate will then fall back to sync serialization, which is safe. + return { size: serialize(value).length, maxStringLen: 0 }; + } +} + +// Regular stringify +export function serialize( + obj, + errorContext?, + replacer?, + spacer?, + options?, +): Uint8Array { + try { + const str = JSON.stringify(obj, createDefaultReplacer(replacer), spacer); + return encodeString(str); + } catch (e: any) { + // Fall back to more complex stringify if circular reference + if (!e.message?.includes("Converting circular structure to JSON")) { + console.warn( + `[WARNING]: LangSmith received unserializable value.${ + errorContext ? `\nContext: ${errorContext}` : "" + }`, + ); + return encodeString("[Unserializable]"); + } + getLangSmithEnvironmentVariable("SUPPRESS_CIRCULAR_JSON_WARNINGS") !== + "true" && + console.warn( + `[WARNING]: LangSmith received circular JSON. This will decrease tracer performance. ${ + errorContext ? `\nContext: ${errorContext}` : "" + }`, + ); + if (typeof options === "undefined") { + options = defaultOptions(); + } + + decirc(obj, "", 0, [], undefined, 0, options); + let res: string; + try { + if (replacerStack.length === 0) { + res = JSON.stringify(obj, replacer, spacer); + } else { + res = JSON.stringify(obj, replaceGetterValues(replacer), spacer); + } + } catch (_) { + return encodeString( + "[unable to serialize, circular reference is too complex to analyze]", + ); + } finally { + while (arr.length !== 0) { + const part = arr.pop(); + if (part.length === 4) { + Object.defineProperty(part[0], part[1], part[3]); + } else { + part[0][part[1]] = part[2]; + } + } + } + return encodeString(res); + } +} + +function setReplace(replace, val, k, parent) { + var propertyDescriptor = Object.getOwnPropertyDescriptor(parent, k); + if (propertyDescriptor.get !== undefined) { + if (propertyDescriptor.configurable) { + Object.defineProperty(parent, k, { value: replace }); + arr.push([parent, k, val, propertyDescriptor]); + } else { + replacerStack.push([val, k, replace]); + } + } else { + parent[k] = replace; + arr.push([parent, k, val]); + } +} + +function decirc(val, k, edgeIndex, stack, parent, depth, options) { + depth += 1; + var i; + if (typeof val === "object" && val !== null) { + for (i = 0; i < stack.length; i++) { + if (stack[i] === val) { + setReplace(CIRCULAR_REPLACE_NODE, val, k, parent); + return; + } + } + + if ( + typeof options.depthLimit !== "undefined" && + depth > options.depthLimit + ) { + setReplace(LIMIT_REPLACE_NODE, val, k, parent); + return; + } + + if ( + typeof options.edgesLimit !== "undefined" && + edgeIndex + 1 > options.edgesLimit + ) { + setReplace(LIMIT_REPLACE_NODE, val, k, parent); + return; + } + + stack.push(val); + // Optimize for Arrays. Big arrays could kill the performance otherwise! + if (Array.isArray(val)) { + for (i = 0; i < val.length; i++) { + decirc(val[i], i, i, stack, val, depth, options); + } + } else { + // Handle well-known types before Object.keys iteration + val = serializeWellKnownTypes(val); + + var keys = Object.keys(val); + for (i = 0; i < keys.length; i++) { + var key = keys[i]; + decirc(val[key], key, i, stack, val, depth, options); + } + } + stack.pop(); + } +} + +// Stable-stringify +function compareFunction(a, b) { + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} + +function deterministicStringify(obj, replacer, spacer, options) { + if (typeof options === "undefined") { + options = defaultOptions(); + } + + var tmp = deterministicDecirc(obj, "", 0, [], undefined, 0, options) || obj; + var res; + try { + if (replacerStack.length === 0) { + res = JSON.stringify(tmp, replacer, spacer); + } else { + res = JSON.stringify(tmp, replaceGetterValues(replacer), spacer); + } + } catch (_) { + return JSON.stringify( + "[unable to serialize, circular reference is too complex to analyze]", + ); + } finally { + // Ensure that we restore the object as it was. + while (arr.length !== 0) { + var part = arr.pop(); + if (part.length === 4) { + Object.defineProperty(part[0], part[1], part[3]); + } else { + part[0][part[1]] = part[2]; + } + } + } + return res; +} + +function deterministicDecirc(val, k, edgeIndex, stack, parent, depth, options) { + depth += 1; + var i; + if (typeof val === "object" && val !== null) { + for (i = 0; i < stack.length; i++) { + if (stack[i] === val) { + setReplace(CIRCULAR_REPLACE_NODE, val, k, parent); + return; + } + } + try { + if (typeof val.toJSON === "function") { + return; + } + } catch (_) { + return; + } + + if ( + typeof options.depthLimit !== "undefined" && + depth > options.depthLimit + ) { + setReplace(LIMIT_REPLACE_NODE, val, k, parent); + return; + } + + if ( + typeof options.edgesLimit !== "undefined" && + edgeIndex + 1 > options.edgesLimit + ) { + setReplace(LIMIT_REPLACE_NODE, val, k, parent); + return; + } + + stack.push(val); + // Optimize for Arrays. Big arrays could kill the performance otherwise! + if (Array.isArray(val)) { + for (i = 0; i < val.length; i++) { + deterministicDecirc(val[i], i, i, stack, val, depth, options); + } + } else { + // Handle well-known types before Object.keys iteration + val = serializeWellKnownTypes(val); + + // Create a temporary object in the required way + var tmp = {}; + var keys = Object.keys(val).sort(compareFunction); + for (i = 0; i < keys.length; i++) { + var key = keys[i]; + deterministicDecirc(val[key], key, i, stack, val, depth, options); + tmp[key] = val[key]; + } + if (typeof parent !== "undefined") { + arr.push([parent, k, val]); + parent[k] = tmp; + } else { + return tmp; + } + } + stack.pop(); + } +} + +// wraps replacer function to handle values we couldn't replace +// and mark them as replaced value +function replaceGetterValues(replacer) { + replacer = + typeof replacer !== "undefined" + ? replacer + : function (k, v) { + return v; + }; + return function (key, val) { + if (replacerStack.length > 0) { + for (var i = 0; i < replacerStack.length; i++) { + var part = replacerStack[i]; + if (part[1] === key && part[0] === val) { + val = part[2]; + replacerStack.splice(i, 1); + break; + } + } + } + return replacer.call(this, key, val); + }; +} diff --git a/src/lib/utils/fs.browser.ts b/src/lib/utils/fs.browser.ts new file mode 100644 index 0000000..ed9197b --- /dev/null +++ b/src/lib/utils/fs.browser.ts @@ -0,0 +1,66 @@ +/** + * File system abstraction (browser stub). + * + * This stub is used in browser/bundler builds via the package.json browser + * field. Async operations are no-ops; sync operations are no-ops that return + * safe defaults. Higher-level consumers (e.g. prompt_cache/fs.browser.ts) may + * still throw on their own if browser use is unsupported at that layer. + */ + +export const path = { + join: (...parts: string[]) => parts.join("/"), + dirname: (p: string) => p.split("/").slice(0, -1).join("/"), +}; + +// --------------------------------------------------------------------------- +// Async operations – no-op in browser +// --------------------------------------------------------------------------- + +export async function mkdir(_dir: string): Promise {} + +export async function writeFileAtomic( + _filePath: string, + _content: string, +): Promise {} + +export async function readdir(_dir: string): Promise { + return []; +} + +export async function stat(_filePath: string): Promise<{ size: number }> { + return { size: 0 }; +} + +export async function unlink(_filePath: string): Promise {} + +// --------------------------------------------------------------------------- +// Sync operations – no-op / safe defaults in browser +// --------------------------------------------------------------------------- + +export function existsSync(_p: string): boolean { + return false; +} + +export function mkdirSync(_dir: string): void {} + +export function writeFileSync(_filePath: string, _content: string): void {} + +export function renameSync(_oldPath: string, _newPath: string): void {} + +export function unlinkSync(_filePath: string): void {} + +export function readFileSync(_filePath: string): string { + return ""; +} + +// --------------------------------------------------------------------------- +// Lock primitives – no-op / safe defaults in browser +// --------------------------------------------------------------------------- + +export async function mkdirExclusive(_dir: string): Promise {} + +export function statMtimeMs(_filePath: string): number | undefined { + return undefined; +} + +export async function rmRecursive(_filePath: string): Promise {} diff --git a/src/lib/utils/fs.ts b/src/lib/utils/fs.ts new file mode 100644 index 0000000..51fbbb6 --- /dev/null +++ b/src/lib/utils/fs.ts @@ -0,0 +1,94 @@ +/** + * File system abstraction (Node.js version). + * + * This file is swapped with fs.browser.ts for browser builds + * via the package.json browser field. + */ + +import * as nodeFs from "node:fs"; +import * as nodeFsPromises from "node:fs/promises"; +import * as nodePath from "node:path"; + +export const path = nodePath; + +// --------------------------------------------------------------------------- +// Async operations (used by trace dump fallback) +// --------------------------------------------------------------------------- + +export async function mkdir(dir: string): Promise { + await nodeFsPromises.mkdir(dir, { recursive: true }); +} + +export async function writeFileAtomic( + filePath: string, + content: string, +): Promise { + const tempPath = `${filePath}.tmp`; + await nodeFsPromises.writeFile(tempPath, content, { + encoding: "utf8", + mode: 0o600, + }); + await nodeFsPromises.rename(tempPath, filePath); +} + +export async function readdir(dir: string): Promise { + return nodeFsPromises.readdir(dir); +} + +export async function stat(filePath: string): Promise<{ size: number }> { + return nodeFsPromises.stat(filePath); +} + +export async function unlink(filePath: string): Promise { + await nodeFsPromises.unlink(filePath); +} + +// --------------------------------------------------------------------------- +// Sync operations (used by prompt cache) +// --------------------------------------------------------------------------- + +export function existsSync(p: string): boolean { + return nodeFs.existsSync(p); +} + +export function mkdirSync(dir: string): void { + nodeFs.mkdirSync(dir, { recursive: true }); +} + +export function writeFileSync(filePath: string, content: string): void { + nodeFs.writeFileSync(filePath, content); +} + +export function renameSync(oldPath: string, newPath: string): void { + nodeFs.renameSync(oldPath, newPath); +} + +export function unlinkSync(filePath: string): void { + nodeFs.unlinkSync(filePath); +} + +export function readFileSync(filePath: string): string { + return nodeFs.readFileSync(filePath, "utf-8"); +} + +// --------------------------------------------------------------------------- +// Lock primitives (used by the OAuth refresh directory lock) +// --------------------------------------------------------------------------- + +export async function mkdirExclusive(dir: string): Promise { + // Non-recursive mkdir throws EEXIST if the directory already exists, which + // is the atomic test-and-set the cross-process lock relies on. + await nodeFsPromises.mkdir(dir, { mode: 0o700 }); +} + +export function statMtimeMs(filePath: string): number | undefined { + try { + return nodeFs.statSync(filePath).mtimeMs; + } catch { + return undefined; + } +} + +export async function rmRecursive(filePath: string): Promise { + await nodeFsPromises.rm(filePath, { recursive: true, force: true }); +} diff --git a/src/lib/utils/guard.ts b/src/lib/utils/guard.ts new file mode 100644 index 0000000..24adf71 --- /dev/null +++ b/src/lib/utils/guard.ts @@ -0,0 +1,11 @@ +import { isEnvTracingEnabled } from "../env.js"; +import { getCurrentRunTree } from "../singletons/traceable.js"; + +/** + * Check if tracing is enabled for the current run tree or environment. + * + * @returns `true` if tracing is enabled, `false` otherwise. + */ +export function isTracingEnabled() { + return getCurrentRunTree(true)?.tracingEnabled ?? isEnvTracingEnabled(); +} diff --git a/src/lib/utils/is-network-error/index.ts b/src/lib/utils/is-network-error/index.ts new file mode 100644 index 0000000..2432f1a --- /dev/null +++ b/src/lib/utils/is-network-error/index.ts @@ -0,0 +1,50 @@ +/* eslint-disable */ +// @ts-nocheck +// is-network-error vendored to avoid import issues +// Source: https://github.com/sindresorhus/is-network-error +const objectToString = Object.prototype.toString; + +const isError = (value) => objectToString.call(value) === "[object Error]"; + +const errorMessages = new Set([ + "network error", // Chrome + "Failed to fetch", // Chrome + "NetworkError when attempting to fetch resource.", // Firefox + "The Internet connection appears to be offline.", // Safari 16 + "Network request failed", // `cross-fetch` + "fetch failed", // Undici (Node.js) + "terminated", // Undici (Node.js) + " A network error occurred.", // Bun (WebKit) + "Network connection lost", // Cloudflare Workers (fetch) +]); + +export default function isNetworkError(error) { + const isValid = + error && + isError(error) && + error.name === "TypeError" && + typeof error.message === "string"; + + if (!isValid) { + return false; + } + + const { message, stack } = error; + + // Safari 17+ has generic message but no stack for network errors + if (message === "Load failed") { + return ( + stack === undefined || + // Sentry adds its own stack trace to the fetch error, so also check for that + "__sentry_captured__" in error + ); + } + + // Deno network errors start with specific text + if (message.startsWith("error sending request for url")) { + return true; + } + + // Standard network error messages + return errorMessages.has(message); +} diff --git a/src/lib/utils/is-network-error/license b/src/lib/utils/is-network-error/license new file mode 100644 index 0000000..fa7ceba --- /dev/null +++ b/src/lib/utils/is-network-error/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lib/utils/jestlike/constants.ts b/src/lib/utils/jestlike/constants.ts new file mode 100644 index 0000000..cc67d74 --- /dev/null +++ b/src/lib/utils/jestlike/constants.ts @@ -0,0 +1,8 @@ +export const UUID5_NAMESPACE = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; + +// From https://stackoverflow.com/a/29497680 +export const STRIP_ANSI_REGEX = + // eslint-disable-next-line no-control-regex + /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + +export const TEST_ID_DELIMITER = ", test_id="; diff --git a/src/lib/utils/jestlike/globals.ts b/src/lib/utils/jestlike/globals.ts new file mode 100644 index 0000000..4f7ea33 --- /dev/null +++ b/src/lib/utils/jestlike/globals.ts @@ -0,0 +1,84 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { Dataset, TracerSession, Example } from "../../schemas.js"; +import { Client, CreateProjectParams } from "../../client.js"; +import { getEnvironmentVariable } from "../env.js"; +import { isEnvTracingEnabled } from "../../env.js"; +import { RunTree } from "../../run_trees.js"; +import { SimpleEvaluationResult } from "./types.js"; + +export const DEFAULT_TEST_CLIENT = new Client(); + +export type TestWrapperAsyncLocalStorageData = { + enableTestTracking?: boolean; + dataset?: Dataset; + createdAt: string; + projectConfig?: Partial; + project?: TracerSession; + setLoggedOutput?: (value: Record) => void; + onFeedbackLogged?: (feedback: SimpleEvaluationResult) => void; + currentExample?: Partial & { syncPromise?: Promise }; + client: Client; + suiteUuid: string; + suiteName: string; + testRootRunTree?: RunTree; + setupPromise?: Promise; + syncExamplePromises?: Map>; +}; + +export const testWrapperAsyncLocalStorageInstance = + new AsyncLocalStorage(); + +export function trackingEnabled(context: TestWrapperAsyncLocalStorageData) { + if (typeof context.enableTestTracking === "boolean") { + return context.enableTestTracking; + } + if (getEnvironmentVariable("LANGSMITH_TEST_TRACKING") === "false") { + return false; + } + return isEnvTracingEnabled(); +} + +export const evaluatorLogFeedbackPromises = new Set(); + +export function _logTestFeedback(params: { + exampleId?: string; + feedback: SimpleEvaluationResult; + context: TestWrapperAsyncLocalStorageData; + runTree?: RunTree; + client: Client; + sourceRunId?: string; +}) { + const { exampleId, feedback, context, runTree, client, sourceRunId } = params; + if (trackingEnabled(context)) { + if (exampleId === undefined) { + throw new Error( + "Could not log feedback to LangSmith: missing example id. Please contact us for help.", + ); + } + if (runTree === undefined) { + throw new Error( + "Could not log feedback to LangSmith: missing run information. Please contact us for help.", + ); + } + evaluatorLogFeedbackPromises.add( + (async () => { + if (context.project == null) { + throw new Error( + "Could not log feedback to LangSmith: missing project information. Please contact us for help.", + ); + } + await context.syncExamplePromises?.get(exampleId); + await client?.logEvaluationFeedback({ + evaluatorResponse: feedback, + run: runTree, + projectId: context.project.id, + sourceInfo: + sourceRunId !== undefined + ? { __run: { run_id: sourceRunId } } + : undefined, + }); + })(), + ); + } + context.onFeedbackLogged?.(feedback); +} diff --git a/src/lib/utils/jestlike/index.ts b/src/lib/utils/jestlike/index.ts new file mode 100644 index 0000000..43164a5 --- /dev/null +++ b/src/lib/utils/jestlike/index.ts @@ -0,0 +1,867 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import crypto from "crypto"; +import { v4, v5 } from "../uuid/src/index.js"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { execSync } from "child_process"; + +import { getCurrentRunTree, traceable } from "../../traceable.js"; +import { KVMap, TracerSession } from "../../schemas.js"; +import { randomName } from "../../evaluation/_random_name.js"; +import { Client, CreateProjectParams } from "../../client.js"; +import { LangSmithConflictError } from "../../utils/error.js"; +import { + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +} from "./matchers.js"; +import { + evaluatorLogFeedbackPromises, + TestWrapperAsyncLocalStorageData, + testWrapperAsyncLocalStorageInstance, + _logTestFeedback, + trackingEnabled, + DEFAULT_TEST_CLIENT, +} from "./globals.js"; +import { wrapExpect } from "./vendor/chain.js"; +import { SimpleEvaluationResult } from "./types.js"; +import type { + LangSmithJestlikeWrapperConfig, + LangSmithJestlikeWrapperParams, + LangSmithJestlikeDescribeWrapper, + LangSmithJestlikeDescribeWrapperConfig, + LangSmithJestlikeTestFunction, +} from "./types.js"; +import { getEnvironmentVariable, isJsDom } from "../env.js"; +import { + STRIP_ANSI_REGEX, + TEST_ID_DELIMITER, + UUID5_NAMESPACE, +} from "./constants.js"; + +export function logFeedback( + feedback: SimpleEvaluationResult, + config?: { sourceRunId?: string }, +) { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if (context === undefined) { + throw new Error( + [ + `Could not retrieve test context. Make sure your logFeedback call is nested within a "ls.describe()" block.`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + if (context.currentExample === undefined) { + throw new Error( + [ + `Could not retrieve current example. Make sure your logFeedback call is nested within a "ls.test()" block.`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + _logTestFeedback({ + ...config, + exampleId: context.currentExample.id, + feedback: feedback, + context, + runTree: context.testRootRunTree, + client: context.client, + }); +} + +export function logOutputs(output: Record) { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if (context === undefined) { + throw new Error( + `Could not retrieve test context. Make sure your logFeedback call is nested within a "ls.describe()" block.`, + ); + } + if ( + context.currentExample === undefined || + context.setLoggedOutput === undefined + ) { + throw new Error( + [ + `Could not retrieve current example. Make sure your logFeedback call is nested within a "ls.test()" block.`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + context.setLoggedOutput(output); +} + +export function _objectHash(obj: KVMap | unknown[], depth = 0): string { + // Prevent infinite recursion + if (depth > 50) { + throw new Error( + "Object is too deep to check equality for serialization. Please use a simpler example.", + ); + } + + if (Array.isArray(obj)) { + const arrayHash = obj.map((item) => _objectHash(item, depth + 1)).join(","); + return crypto.createHash("sha256").update(arrayHash).digest("hex"); + } + + if (obj && typeof obj === "object") { + const sortedHash = Object.keys(obj) + .sort() + .map((key) => `${key}:${_objectHash(obj[key], depth + 1)}`) + .join(","); + return crypto.createHash("sha256").update(sortedHash).digest("hex"); + } + + return ( + crypto + .createHash("sha256") + // Treat null and undefined as equal for serialization purposes + .update(JSON.stringify(obj ?? null)) + .digest("hex") + ); +} + +export function generateWrapperFromJestlikeMethods( + methods: Record, + testRunnerName: string, +) { + const { expect, test, describe, beforeAll, afterAll } = methods; + + async function _createProject( + client: Client, + datasetId: string, + projectConfig?: Partial, + ) { + // Create the project, updating the experimentName until we find a unique one. + let project: TracerSession; + let experimentName = randomName(); + for (let i = 0; i < 10; i++) { + try { + project = await client.createProject({ + projectName: experimentName, + ...projectConfig, + referenceDatasetId: datasetId, + }); + return project; + } catch (e) { + // Naming collision + if ((e as LangSmithConflictError)?.name === "LangSmithConflictError") { + const ent = v4().slice(0, 6); + experimentName = `${experimentName}-${ent}`; + } else { + throw e; + } + } + } + throw new Error( + "Could not generate a unique experiment name within 10 attempts." + + " Please try again.", + ); + } + + const datasetSetupInfo = new Map(); + + function getExampleId( + datasetId: string, + inputs: Record, + outputs?: Record, + providedId?: string, + ) { + if (providedId) { + return providedId; + } + const identifier = JSON.stringify({ + datasetId, + inputsHash: _objectHash(inputs), + outputsHash: _objectHash(outputs ?? {}), + }); + return v5(identifier, UUID5_NAMESPACE); + } + + async function syncExample(params: { + client: Client; + exampleId: string; + datasetId: string; + inputs: Record; + outputs: Record; + metadata?: Record; + split?: string | string[]; + createdAt: string; + }) { + const { + client, + exampleId, + inputs, + outputs, + metadata, + split, + createdAt, + datasetId, + } = params; + let example; + try { + example = await client.readExample(exampleId); + const normalizedSplit = split + ? typeof split === "string" + ? [split] + : split + : undefined; + const { dataset_split, ...restMetadata } = example.metadata ?? {}; + if ( + _objectHash(example.inputs) !== _objectHash(inputs) || + _objectHash(example.outputs ?? {}) !== _objectHash(outputs ?? {}) || + example.dataset_id !== datasetId || + (normalizedSplit !== undefined && + _objectHash(dataset_split ?? []) !== + _objectHash(normalizedSplit ?? [])) || + (metadata !== undefined && + _objectHash(restMetadata ?? {}) !== _objectHash(metadata ?? {})) + ) { + await client.updateExample(exampleId, { + inputs, + outputs, + metadata, + split, + dataset_id: datasetId, + }); + } + } catch (e: any) { + if (e.message.includes("not found")) { + example = await client.createExample(inputs, outputs, { + exampleId, + datasetId, + createdAt: new Date(createdAt ?? new Date()), + split, + metadata, + }); + } else { + throw e; + } + } + return example; + } + + async function runDatasetSetup(context: TestWrapperAsyncLocalStorageData) { + const { + client: testClient, + suiteName: datasetName, + projectConfig, + } = context; + let storageValue; + if (!trackingEnabled(context)) { + storageValue = { + createdAt: new Date().toISOString(), + }; + } else { + let dataset; + try { + dataset = await testClient.readDataset({ + datasetName, + }); + } catch (e: any) { + if (e.message.includes("not found")) { + dataset = await testClient.createDataset(datasetName, { + description: `Dataset for unit tests created on ${new Date().toISOString()}`, + metadata: { __ls_runner: testRunnerName }, + }); + } else { + throw e; + } + } + const project = await _createProject( + testClient, + dataset.id, + projectConfig, + ); + const datasetUrl = await testClient.getDatasetUrl({ + datasetId: dataset.id, + }); + const experimentUrl = `${datasetUrl}/compare?selectedSessions=${project.id}`; + console.log( + `[LANGSMITH]: Experiment starting for dataset "${datasetName}"!\n[LANGSMITH]: View results at ${experimentUrl}`, + ); + storageValue = { + dataset, + project, + client: testClient, + experimentUrl, + }; + } + return storageValue; + } + + function wrapDescribeMethod( + method: (name: string, fn: () => void | Promise) => void, + methodName: string, + ): LangSmithJestlikeDescribeWrapper { + if (isJsDom()) { + console.error( + `[LANGSMITH]: You seem to be using a jsdom environment. This is not supported and you may experience unexpected behavior. Please set the "environment" or "testEnvironment" field in your test config file to "node".`, + ); + } + return function ( + testSuiteName: string, + fn: () => void | Promise, + experimentConfig?: LangSmithJestlikeDescribeWrapperConfig, + ) { + if (typeof method !== "function") { + throw new Error( + `"${methodName}" is not supported by your test runner.`, + ); + } + if (testWrapperAsyncLocalStorageInstance.getStore() !== undefined) { + throw new Error( + [ + `You seem to be nesting an ls.describe block named "${testSuiteName}" inside another ls.describe block.`, + "This is not supported because each ls.describe block corresponds to a LangSmith dataset.", + "To logically group tests, nest the native Jest or Vitest describe methods instead.", + ].join("\n"), + ); + } + const client = experimentConfig?.client ?? DEFAULT_TEST_CLIENT; + const suiteName = experimentConfig?.testSuiteName ?? testSuiteName; + let setupPromiseResolver; + const setupPromise = new Promise((resolve) => { + setupPromiseResolver = resolve; + }); + return method(suiteName, () => { + const startTime = new Date(); + const suiteUuid = v4(); + const environment = + experimentConfig?.metadata?.ENVIRONMENT ?? + getEnvironmentVariable("ENVIRONMENT"); + const nodeEnv = + experimentConfig?.metadata?.NODE_ENV ?? + getEnvironmentVariable("NODE_ENV"); + const langsmithEnvironment = + experimentConfig?.metadata?.LANGSMITH_ENVIRONMENT ?? + getEnvironmentVariable("LANGSMITH_ENVIRONMENT"); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const suiteMetadata: Record = { + ...experimentConfig?.metadata, + __ls_runner: testRunnerName, + }; + if (environment !== undefined) { + suiteMetadata.ENVIRONMENT = environment; + } + if (nodeEnv !== undefined) { + suiteMetadata.NODE_ENV = nodeEnv; + } + if (langsmithEnvironment !== undefined) { + suiteMetadata.LANGSMITH_ENVIRONMENT = langsmithEnvironment; + } + const context = { + suiteUuid, + suiteName, + client, + createdAt: new Date().toISOString(), + projectConfig: { + ...experimentConfig, + metadata: suiteMetadata, + }, + enableTestTracking: experimentConfig?.enableTestTracking, + setupPromise, + syncExamplePromises: new Map(), + }; + + beforeAll(async () => { + const storageValue = await runDatasetSetup(context); + datasetSetupInfo.set(suiteUuid, storageValue); + setupPromiseResolver!(); + }); + + afterAll(async () => { + const examples = await Promise.all( + context.syncExamplePromises.values(), + ); + await Promise.all([ + client.awaitPendingTraceBatches(), + ...evaluatorLogFeedbackPromises.values(), + ]); + if (!trackingEnabled(context)) { + return; + } + if (examples.length === 0) { + return; + } + const endTime = new Date(); + let branch; + let commit; + let dirty; + try { + branch = execSync("git rev-parse --abbrev-ref HEAD", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + commit = execSync("git rev-parse HEAD", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim(); + dirty = + execSync("git status --porcelain", { + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() !== ""; + } catch { + return; + } + if (branch === undefined || commit === undefined) { + return; + } + try { + let finalModifiedAt = examples.reduce( + (latestModifiedAt, example) => { + if ( + new Date(latestModifiedAt).getTime() > + new Date(example.modified_at).getTime() + ) { + return latestModifiedAt; + } else { + return example.modified_at; + } + }, + examples[0].modified_at, + ); + if (new Date(finalModifiedAt).getTime() < startTime.getTime()) { + finalModifiedAt = endTime.toISOString(); + } + const datasetInfo = datasetSetupInfo.get(suiteUuid); + await client.updateProject(datasetInfo.project.id, { + metadata: { + ...suiteMetadata, + dataset_version: finalModifiedAt, + commit, + branch, + dirty, + }, + }); + await client.updateDatasetTag({ + datasetId: datasetInfo.dataset.id, + asOf: finalModifiedAt, + tag: `git:commit:${commit}`, + }); + } catch (e) { + console.error(e); + return; + } + }); + + /** + * We cannot rely on setting AsyncLocalStorage in beforeAll or beforeEach, + * due to https://github.com/jestjs/jest/issues/13653 and needing to use + * the janky .enterWith. + * + * We also cannot do async setup in describe due to Jest restrictions. + * However, .run without asynchronous logic works. + * + * We really just need a way to pass suiteUuid as global state to inner tests + * that can handle concurrently running test suites. If we drop the + * concurrency requirement, we can remove this hack. + */ + void testWrapperAsyncLocalStorageInstance.run(context, fn); + }); + }; + } + + const lsDescribe = Object.assign(wrapDescribeMethod(describe, "describe"), { + only: wrapDescribeMethod(describe.only, "describe.only"), + skip: wrapDescribeMethod(describe.skip, "describe.skip"), + concurrent: wrapDescribeMethod(describe.concurrent, "describe.concurrent"), + }); + + function wrapTestMethod(method: (...args: any[]) => void) { + return function < + I extends Record = Record, + O extends Record = Record, + >( + name: string, + lsParams: LangSmithJestlikeWrapperParams, + testFn: LangSmithJestlikeTestFunction, + timeout?: number, + ) { + // Due to https://github.com/jestjs/jest/issues/13653, + // we must access the local store value here before + // doing anything async. + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if ( + context !== undefined && + lsParams.config?.enableTestTracking !== undefined + ) { + context.enableTestTracking = lsParams.config.enableTestTracking; + } + const { id, config, inputs, split, metadata, ...rest } = lsParams; + let referenceOutputs: O | undefined = rest.referenceOutputs; + if (!referenceOutputs && "outputs" in rest) { + referenceOutputs = rest.outputs as O; + } + const totalRuns = config?.repetitions ?? 1; + for (let i = 0; i < totalRuns; i += 1) { + const testUuid = v4().replace(/-/g, "").slice(0, 13); + // Jest will not group tests under the same "describe" group if you await the test and + // total runs is greater than 1. + const resultsPath = path.join( + os.tmpdir(), + "langsmith_test_results", + `${testUuid}.json`, + ); + void method( + `${name}${ + totalRuns > 1 ? `, run ${i}` : "" + }${TEST_ID_DELIMITER}${testUuid}`, + async (...args: any[]) => { + // Jest will magically introspect args and pass a "done" callback if + // we use a non-spread parameter. To obtain and pass Vitest test context + // through into the test function, we must therefore refer to Vitest + // args using this signature + const jestlikeArgs = args[0]; + if (context === undefined) { + throw new Error( + [ + `Could not retrieve test context.`, + `Please make sure you have tracing enabled and you are wrapping all of your test cases in an "ls.describe()" function.`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + // Jest .concurrent is super buggy and doesn't wait for beforeAll to complete + // before running test functions, so we need to wait for the setup promise + // to resolve before we can continue. + // Seee https://github.com/jestjs/jest/issues/4281 + await context.setupPromise; + if (!datasetSetupInfo.get(context.suiteUuid)) { + throw new Error( + "Dataset failed to initialize. Please check your LangSmith environment variables.", + ); + } + const { dataset, createdAt, project, client, experimentUrl } = + datasetSetupInfo.get(context.suiteUuid); + const testInput: I = inputs; + const testOutput: O = referenceOutputs ?? ({} as O); + const testFeedback: SimpleEvaluationResult[] = []; + const onFeedbackLogged = (feedback: SimpleEvaluationResult) => + testFeedback.push(feedback); + let loggedOutput: Record | undefined; + const setLoggedOutput = (value: Record) => { + if (loggedOutput !== undefined) { + console.warn( + `[WARN]: New "logOutputs()" call will override output set by previous "logOutputs()" call.`, + ); + } + loggedOutput = value; + }; + let exampleId: string; + const runTestFn = async () => { + let testContext = testWrapperAsyncLocalStorageInstance.getStore(); + if (testContext === undefined) { + throw new Error( + "Could not identify test context. Please contact us for help.", + ); + } + return testWrapperAsyncLocalStorageInstance.run( + { + ...testContext, + testRootRunTree: trackingEnabled(testContext) + ? getCurrentRunTree() + : undefined, + }, + async () => { + testContext = testWrapperAsyncLocalStorageInstance.getStore(); + if (testContext === undefined) { + throw new Error( + "Could not identify test context after setting test root run tree. Please contact us for help.", + ); + } + try { + const res = await testFn( + Object.assign( + typeof jestlikeArgs === "object" && jestlikeArgs != null + ? jestlikeArgs + : {}, + { + ...rest, + inputs: testInput, + referenceOutputs: testOutput, + testMetadata: { + exampleId, + experimentId: project?.id, + datasetId: dataset?.id, + testTrackingEnabled: trackingEnabled(testContext), + repetition: i, + split, + }, + }, + ), + ); + _logTestFeedback({ + exampleId, + feedback: { key: "pass", score: true }, + context: testContext, + runTree: testContext.testRootRunTree, + client: testContext.client, + }); + if (res != null) { + if (loggedOutput !== undefined) { + console.warn( + `[WARN]: Returned value from test function will override output set by previous "logOutputs()" call.`, + ); + } + loggedOutput = + typeof res === "object" + ? (res as Record) + : { result: res }; + } + return loggedOutput; + } catch (e: any) { + _logTestFeedback({ + exampleId, + feedback: { key: "pass", score: false }, + context: testContext, + runTree: testContext.testRootRunTree, + client: testContext.client, + }); + const rawError = e; + const strippedErrorMessage = e.message.replace( + STRIP_ANSI_REGEX, + "", + ); + const langsmithFriendlyError = new Error( + strippedErrorMessage, + ); + (langsmithFriendlyError as any).rawJestError = rawError; + if (testContext.testRootRunTree) { + testContext.testRootRunTree.outputs = loggedOutput; + } + throw langsmithFriendlyError; + } + }, + ); + }; + try { + if (trackingEnabled(context)) { + const missingFields = []; + if (dataset === undefined) { + missingFields.push("dataset"); + } + if (project === undefined) { + missingFields.push("project"); + } + if (client === undefined) { + missingFields.push("client"); + } + if (missingFields.length > 0) { + throw new Error( + `Failed to initialize test tracking: Could not identify ${missingFields + .map((field) => `"${field}"`) + .join( + ", ", + )} while syncing to LangSmith. Please contact us for help.`, + ); + } + exampleId = getExampleId( + dataset.id, + inputs, + referenceOutputs, + id, + ); + + // TODO: Create or update the example in the background + // Currently run end time has to be after example modified time + // for examples to render properly, so we must modify the example + // first before running the test. + if (context.syncExamplePromises === undefined) { + throw new Error( + "Internal error: syncExamplePromises not initialized. This is a bug in the LangSmith SDK — please open an issue at https://github.com/langchain-ai/langsmith-sdk/issues.", + ); + } + if (!context.syncExamplePromises.has(exampleId)) { + const syncPromise = syncExample({ + client, + exampleId, + datasetId: dataset.id, + inputs, + outputs: referenceOutputs ?? {}, + metadata, + split, + createdAt, + }); + context.syncExamplePromises.set(exampleId, syncPromise); + } + await context.syncExamplePromises.get(exampleId); + + const traceableOptions = { + reference_example_id: exampleId, + project_name: project!.name, + metadata: { + ...config?.metadata, + }, + client, + tracingEnabled: true, + name, + }; + + // Pass inputs into traceable so tracing works correctly but + // provide both to the user-defined test function + const tracedFunction = traceable( + async () => { + return testWrapperAsyncLocalStorageInstance.run( + { + ...context, + dataset, + project, + client, + currentExample: { + inputs, + outputs: referenceOutputs, + id: exampleId, + }, + setLoggedOutput, + onFeedbackLogged, + }, + runTestFn, + ); + }, + { + ...traceableOptions, + ...config, + }, + ); + try { + await tracedFunction(testInput); + } catch (e: any) { + // Extract raw Jest error from LangSmith formatted one and throw + if (e.rawJestError !== undefined) { + throw e.rawJestError; + } + throw e; + } + } else { + try { + await testWrapperAsyncLocalStorageInstance.run( + { + ...context, + currentExample: { + inputs: testInput, + outputs: testOutput, + }, + setLoggedOutput, + onFeedbackLogged, + }, + runTestFn, + ); + } catch (e: any) { + // Extract raw Jest error from LangSmith formatted one and throw + if (e.rawJestError !== undefined) { + throw e.rawJestError; + } + throw e; + } + } + } finally { + await fs.mkdir(path.dirname(resultsPath), { recursive: true }); + await fs.writeFile( + resultsPath, + JSON.stringify({ + inputs, + referenceOutputs, + outputs: loggedOutput, + feedback: testFeedback, + experimentUrl, + }), + ); + } + }, + timeout, + ); + } + }; + } + + function createEachMethod(method: (...args: any[]) => void) { + function eachMethod( + table: ({ id?: string; inputs: I; referenceOutputs?: O } & Record< + string, + any + >)[], + config?: LangSmithJestlikeWrapperConfig, + ) { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if (context === undefined) { + throw new Error( + [ + `Could not retrieve test context. Make sure your test is nested within a "ls.describe()" block.`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + return function ( + name: string, + fn: LangSmithJestlikeTestFunction, + timeout?: number, + ) { + for (let i = 0; i < table.length; i += 1) { + const example = table[i]; + wrapTestMethod(method)( + `${name}, item ${i}`, + { + ...example, + inputs: example.inputs, + referenceOutputs: example.referenceOutputs, + config, + }, + fn, + timeout, + ); + } + }; + } + return eachMethod; + } + + // Roughly mirrors: https://jestjs.io/docs/api#methods + const concurrentMethod = Object.assign(wrapTestMethod(test.concurrent), { + each: createEachMethod(test.concurrent), + only: Object.assign(wrapTestMethod(test.concurrent.only), { + each: createEachMethod(test.concurrent.only), + }), + skip: Object.assign(wrapTestMethod(test.concurrent.skip), { + each: createEachMethod(test.concurrent.skip), + }), + }); + + const lsTest = Object.assign(wrapTestMethod(test), { + only: Object.assign(wrapTestMethod(test.only), { + each: createEachMethod(test.only), + }), + skip: Object.assign(wrapTestMethod(test.skip), { + each: createEachMethod(test.skip), + }), + concurrent: concurrentMethod, + each: createEachMethod(test), + }); + + const wrappedExpect = wrapExpect(expect); + + return { + test: lsTest, + it: lsTest, + describe: lsDescribe, + expect: wrappedExpect, + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, + }; +} + +export function isInTestContext() { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + return context !== undefined; +} + +export { wrapEvaluator } from "./vendor/evaluatedBy.js"; + +export * from "./types.js"; diff --git a/src/lib/utils/jestlike/matchers.ts b/src/lib/utils/jestlike/matchers.ts new file mode 100644 index 0000000..00e21dd --- /dev/null +++ b/src/lib/utils/jestlike/matchers.ts @@ -0,0 +1,160 @@ +// Levenshtein distance implementation +function levenshteinDistance(a: string, b: string): number { + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + const matrix = Array(b.length + 1) + .fill(null) + .map(() => Array(a.length + 1).fill(null)); + + for (let i = 0; i <= a.length; i++) matrix[0][i] = i; + for (let j = 0; j <= b.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= b.length; j++) { + for (let i = 1; i <= a.length; i++) { + const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, + matrix[j - 1][i] + 1, + matrix[j - 1][i - 1] + substitutionCost, + ); + } + } + return matrix[b.length][a.length]; +} + +export type RelativeCloseToMatcherOptions = { + threshold?: number; + algorithm?: "levenshtein"; +}; + +export async function toBeRelativeCloseTo( + received: string, + expected: string, + options: RelativeCloseToMatcherOptions = {}, +) { + const { threshold = 0.1, algorithm = "levenshtein" } = options; + + if (threshold < 0 || threshold > 1) { + throw new Error( + "Relative distance is normalized, and threshold must be between 0 and 1.", + ); + } + + let distance: number; + let maxLength: number; + + switch (algorithm) { + case "levenshtein": + distance = levenshteinDistance(received, expected); + maxLength = Math.max(received.length, expected.length); + break; + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + // Calculate relative distance (normalized between 0 and 1) + const relativeDistance = maxLength === 0 ? 0 : distance / maxLength; + const pass = relativeDistance <= threshold; + + return { + pass, + message: () => + pass + ? `Expected "${received}" not to be relatively close to "${expected}" (threshold: ${threshold}, actual distance: ${relativeDistance})` + : `Expected "${received}" to be relatively close to "${expected}" (threshold: ${threshold}, actual distance: ${relativeDistance})`, + }; +} + +export type AbsoluteCloseToMatcherOptions = { + threshold?: number; + algorithm?: "levenshtein"; +}; + +export async function toBeAbsoluteCloseTo( + received: string, + expected: string, + options: AbsoluteCloseToMatcherOptions = {}, +) { + const { threshold = 3, algorithm = "levenshtein" } = options; + + let distance: number; + + switch (algorithm) { + case "levenshtein": + distance = levenshteinDistance(received, expected); + break; + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + const pass = distance <= threshold; + + return { + pass, + message: () => + pass + ? `Expected "${received}" not to be absolutely close to "${expected}" (threshold: ${threshold}, actual distance: ${distance})` + : `Expected "${received}" to be absolutely close to "${expected}" (threshold: ${threshold}, actual distance: ${distance})`, + }; +} + +export type SemanticCloseToMatcherOptions = { + embeddings: { embedQuery: (query: string) => number[] | Promise }; + threshold?: number; + algorithm?: "cosine" | "dot-product"; +}; + +export async function toBeSemanticCloseTo( + received: string, + expected: string, + options: SemanticCloseToMatcherOptions, +) { + const { threshold = 0.2, embeddings, algorithm = "cosine" } = options; + + // Get embeddings for both strings + const [receivedEmbedding, expectedEmbedding] = await Promise.all([ + embeddings.embedQuery(received), + embeddings.embedQuery(expected), + ]); + + // Calculate similarity based on chosen algorithm + let similarity: number; + switch (algorithm) { + case "cosine": { + // Compute cosine similarity + const dotProduct = receivedEmbedding.reduce( + (sum, a, i) => sum + a * expectedEmbedding[i], + 0, + ); + const receivedMagnitude = Math.sqrt( + receivedEmbedding.reduce((sum, a) => sum + a * a, 0), + ); + const expectedMagnitude = Math.sqrt( + expectedEmbedding.reduce((sum, a) => sum + a * a, 0), + ); + similarity = dotProduct / (receivedMagnitude * expectedMagnitude); + break; + } + case "dot-product": { + // Compute dot product similarity + similarity = receivedEmbedding.reduce( + (sum, a, i) => sum + a * expectedEmbedding[i], + 0, + ); + break; + } + default: + throw new Error(`Unsupported algorithm: ${algorithm}`); + } + + const pass = similarity >= 1 - threshold; + + return { + pass, + message: () => + pass + ? `Expected "${received}" not to be semantically close to "${expected}" (threshold: ${threshold}, similarity: ${similarity})` + : `Expected "${received}" to be semantically close to "${expected}" (threshold: ${threshold}, similarity: ${similarity})`, + }; +} diff --git a/src/lib/utils/jestlike/reporter.ts b/src/lib/utils/jestlike/reporter.ts new file mode 100644 index 0000000..057158d --- /dev/null +++ b/src/lib/utils/jestlike/reporter.ts @@ -0,0 +1,293 @@ +import { Table } from "../console-table-printer/index.js"; +import chalk from "../chalk/source/index.js"; + +import * as os from "node:os"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { SimpleEvaluationResult } from "./types.js"; +import { ScoreType } from "../../schemas.js"; +import { STRIP_ANSI_REGEX, TEST_ID_DELIMITER } from "./constants.js"; + +const FEEDBACK_COLLAPSE_THRESHOLD = 48; +const MAX_TEST_PARAMS_LENGTH = 18; + +const RESERVED_KEYS = [ + "Name", + "Result", + "Inputs", + "Reference Outputs", + "Outputs", + "pass", +]; + +function formatTestName(name: string, duration: number) { + if (duration != null) { + return `${name} (${duration}ms)`; + } else { + return name; + } +} + +function getFormattedStatus(status: string) { + const s = status.toLowerCase(); + if (s === "pending" || s === "skipped") { + return chalk.yellow("○ Skipped"); + } else if (s.includes("pass")) { + return chalk.green("✓ Passed"); + } else if (s.includes("fail")) { + return chalk.red("✕ Failed"); + } else { + return status; + } +} + +function getColorParam(status: string) { + const s = status.toLowerCase(); + if (s === "pending" || s === "skipped") { + return { color: "yellow" }; + } else if (s.includes("pass")) { + return { color: "grey" }; + } else if (s.includes("fail")) { + return { color: "red" }; + } else { + return {}; + } +} + +function formatValue(value: unknown) { + if (typeof value === "object" && value !== null) { + return Object.entries(value) + .map(([k, v]) => { + const rawValue = typeof v === "string" ? v : JSON.stringify(v); + const rawEntry = `${k}: ${rawValue}`; + const entry = + rawEntry.length > MAX_TEST_PARAMS_LENGTH + ? rawEntry.slice(0, MAX_TEST_PARAMS_LENGTH - 3) + "..." + : rawEntry; + return entry; + }) + .join("\n"); + } + if (value == null) { + return; + } + return String(value); +} + +export async function printReporterTable( + testSuiteName: string, + results: { + title: string; + duration: number; + status: "pass" | "passed" | "fail" | "failed" | "pending" | "skipped"; + }[], + testStatus: "pass" | "skip" | "fail" | "passed" | "failed" | "skipped", + failureMessage?: string, +) { + const rows = []; + const feedbackKeys = new Set(); + let experimentUrl; + for (const result of results) { + const { title, duration, status } = result; + const titleComponents = title.split(TEST_ID_DELIMITER); + const testId = + titleComponents.length > 1 && titleComponents.at(-1) !== undefined + ? titleComponents.at(-1) + : undefined; + const testName = + testId !== undefined + ? titleComponents.slice(0, -1).join(TEST_ID_DELIMITER).trim() + : titleComponents.join(TEST_ID_DELIMITER); + // Non-LangSmith test + if (testId === undefined) { + rows.push([ + { + Test: formatTestName(testName, duration), + Status: getFormattedStatus(status), + }, + getColorParam(status), + ]); + } else if (status === "pending" || status === "skipped") { + // Skipped + rows.push([ + { + Test: formatTestName(testName, duration), + Status: getFormattedStatus(status), + }, + getColorParam(status), + ]); + } else { + const resultsPath = path.join( + os.tmpdir(), + "langsmith_test_results", + `${testId}.json`, + ); + let fileContent; + try { + fileContent = JSON.parse(await fs.readFile(resultsPath, "utf-8")); + await fs.unlink(resultsPath); + } catch (_e) { + console.log( + "[LANGSMITH]: Failed to read custom evaluation results. Please contact us for help.", + ); + rows.push([ + { + Test: formatTestName(testName, duration), + Status: getFormattedStatus(status), + }, + getColorParam(status), + ]); + continue; + } + const feedback = fileContent.feedback.reduce( + (acc: Record, current: SimpleEvaluationResult) => { + if ( + !RESERVED_KEYS.includes(current.key) && + current.score !== undefined + ) { + feedbackKeys.add(current.key); + acc[current.key] = current.score; + } + return acc; + }, + {}, + ); + experimentUrl = experimentUrl ?? fileContent.experimentUrl; + rows.push([ + { + Test: formatTestName(testName, duration), + Inputs: formatValue(fileContent.inputs), + "Reference Outputs": formatValue(fileContent.referenceOutputs), + Outputs: formatValue(fileContent.outputs), + Status: getFormattedStatus(status), + ...feedback, + }, + getColorParam(status), + ]); + } + } + + const feedbackKeysTotalLength = [...feedbackKeys].reduce( + (l, key) => l + key.length, + 0, + ); + const collapseFeedbackColumn = + feedbackKeysTotalLength > FEEDBACK_COLLAPSE_THRESHOLD; + for (const key of feedbackKeys) { + const scores = rows + .map(([row]) => row[key]) + .filter((score) => score !== undefined); + if (scores.length > 0) { + const mean = scores.reduce((a, b) => a + b, 0) / scores.length; + const stdDev = Math.sqrt( + scores.reduce((sq, n) => sq + Math.pow(n - mean, 2), 0) / scores.length, + ); + for (const row of rows) { + const score = row[0][key]; + if (score !== undefined) { + const deviation = (score - mean) / stdDev; + let coloredKey; + let coloredScore; + if (isNaN(deviation)) { + coloredKey = chalk.white(`${key}:`); + coloredScore = chalk.white(score); + } else if (deviation <= -1) { + coloredKey = chalk.redBright(`${key}:`); + coloredScore = chalk.redBright(score); + } else if (deviation < -0.5) { + coloredKey = chalk.red(`${key}:`); + coloredScore = chalk.red(score); + } else if (deviation < 0) { + coloredKey = chalk.yellow(`${key}:`); + coloredScore = chalk.yellow(score); + } else if (deviation === 0) { + coloredKey = chalk.white(`${key}:`); + coloredScore = chalk.white(score); + } else if (deviation <= 0.5) { + coloredKey = chalk.green(`${key}:`); + coloredScore = chalk.green(score); + } else { + coloredKey = chalk.greenBright(`${key}:`); + coloredScore = chalk.greenBright(score); + } + if (collapseFeedbackColumn) { + delete row[0][key]; + if (row[0].Feedback === undefined) { + row[0].Feedback = `${coloredKey} ${coloredScore}`; + } else { + row[0].Feedback = `${row[0].Feedback}\n${coloredKey} ${coloredScore}`; + } + } else { + row[0][key] = coloredScore; + } + } + } + } + } + + const defaultColumns: { + name: string; + alignment?: string; + maxLen?: number; + minLen?: number; + }[] = [ + { name: "Test", alignment: "left", maxLen: 36 }, + { name: "Inputs", alignment: "left", minLen: MAX_TEST_PARAMS_LENGTH }, + { + name: "Reference Outputs", + alignment: "left", + minLen: MAX_TEST_PARAMS_LENGTH, + }, + { name: "Outputs", alignment: "left", minLen: MAX_TEST_PARAMS_LENGTH }, + { name: "Status", alignment: "left" }, + ]; + if (collapseFeedbackColumn) { + const feedbackColumnLength = rows.reduce((max, [row]) => { + const maxFeedbackLineLength = + row.Feedback?.split("\n").reduce( + (max: number, feedbackLine: string) => { + return Math.max( + max, + feedbackLine.replace(STRIP_ANSI_REGEX, "").length, + ); + }, + 0, + ) ?? 0; + return Math.max(max, maxFeedbackLineLength); + }, 0); + defaultColumns.push({ + name: "Feedback", + alignment: "left", + minLen: feedbackColumnLength + 8, + }); + } + console.log(); + const table = new Table({ + columns: defaultColumns, + colorMap: { + grey: "\x1b[90m", + }, + }); + for (const row of rows) { + table.addRow(row[0], row[1]); + } + const testStatusColor = testStatus.includes("pass") + ? chalk.green + : testStatus.includes("fail") + ? chalk.red + : chalk.yellow; + if (testSuiteName) { + console.log(testStatusColor(`› ${testSuiteName}`)); + } + if (failureMessage) { + console.log(failureMessage); + } + table.printTable(); + if (experimentUrl) { + console.log(); + console.log( + ` [LANGSMITH]: View full results in LangSmith at ${experimentUrl}`, + ); + console.log(); + } +} diff --git a/src/lib/utils/jestlike/types.ts b/src/lib/utils/jestlike/types.ts new file mode 100644 index 0000000..58d0da8 --- /dev/null +++ b/src/lib/utils/jestlike/types.ts @@ -0,0 +1,60 @@ +import type { + CreateProjectParams, + CreateExampleOptions, + Client, +} from "../../client.js"; +import type { EvaluationResult } from "../../evaluation/evaluator.js"; +import type { RunTreeConfig } from "../../run_trees.js"; +import type { SimpleEvaluator } from "./vendor/evaluatedBy.js"; + +export { type SimpleEvaluator }; + +export type LangSmithJestlikeWrapperConfig = Partial< + Omit +> & { + repetitions?: number; + enableTestTracking?: boolean; +}; + +export type LangSmithJestlikeWrapperParams = { + id?: string; + inputs: I; + referenceOutputs?: O; + config?: LangSmithJestlikeWrapperConfig; +} & Pick; + +export type LangSmithJestlikeDescribeWrapperConfig = { + client?: Client; + enableTestTracking?: boolean; + testSuiteName?: string; +} & Partial>; + +export type LangSmithJestlikeDescribeWrapper = ( + name: string, + fn: () => void | Promise, + config?: LangSmithJestlikeDescribeWrapperConfig, +) => void; + +export type SimpleEvaluationResult = { + key: EvaluationResult["key"]; + score: NonNullable; + comment?: EvaluationResult["comment"]; +}; + +export type LangSmithJestlikeTestMetadata = { + exampleId?: string; + experimentId?: string; + datasetId?: string; + testTrackingEnabled: boolean; + repetition: number; + split?: string | string[]; +}; + +export type LangSmithJestlikeTestFunction = ( + data: { + inputs: I; + referenceOutputs?: O; + testMetadata: LangSmithJestlikeTestMetadata; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } & Record, +) => unknown | Promise; diff --git a/src/lib/utils/jestlike/vendor/LICENSE b/src/lib/utils/jestlike/vendor/LICENSE new file mode 100644 index 0000000..7fc832e --- /dev/null +++ b/src/lib/utils/jestlike/vendor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018-present Matt Phillips (mattphillips.io) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/lib/utils/jestlike/vendor/chain.ts b/src/lib/utils/jestlike/vendor/chain.ts new file mode 100644 index 0000000..387fc6d --- /dev/null +++ b/src/lib/utils/jestlike/vendor/chain.ts @@ -0,0 +1,124 @@ +/** + * Adapted from https://github.com/mattphillips/jest-chain/blob/main/src/chain.js + */ +import { evaluatedBy, SimpleEvaluator } from "./evaluatedBy.js"; + +class JestlikeAssertionError extends Error { + matcherResult: any; + + constructor(result: any, callsite: any) { + super( + typeof result.message === "function" ? result.message() : result.message, + ); + this.matcherResult = result; + + if (Error.captureStackTrace) { + Error.captureStackTrace(this, callsite); + } + } +} + +const _wrapMatchers = ( + matchers: any, + evaluator: SimpleEvaluator, + originalArgs: any[], + originalExpect: any, + staticPath: string[] = [], +) => { + return Object.keys(matchers) + .filter((name) => typeof (matchers as any)[name] === "function") + .map((name) => { + const newMatcher = async (...args: any[]) => { + try { + const score = await evaluatedBy(originalArgs[0], evaluator); + let result: any = originalExpect(score); + for (const pathEntry of staticPath) { + result = result[pathEntry]; + } + result = result[name](...args); // run matcher up to current state + if (result && typeof result.then === "function") { + return Object.assign(Promise.resolve(result), matchers); + } else { + return matchers; + } + } catch (error: any) { + if (!error.matcherResult) { + throw error; + } else { + throw new JestlikeAssertionError(error.matcherResult, newMatcher); + } + } + }; + return { [name]: newMatcher }; + }); +}; + +const addEvaluatedBy = ( + matchers: any, + originalArgs: any[], + originalExpect: any, + staticPath: string[] = [], +) => { + let spreadMatchers = { ...matchers }; + // Handle Bun, which uses a class, and Vitest which uses something weird + if ( + Object.keys(matchers).length === 0 || + !Object.keys(matchers).includes("toEqual") + ) { + const prototypeProps = Object.getOwnPropertyNames( + Object.getPrototypeOf(matchers), + ); + spreadMatchers = Object.fromEntries( + prototypeProps.map((prop) => { + try { + return [prop, (matchers as any)[prop]]; + } catch (_e) { + // Ignore bizarre Bun bug + return []; + } + }), + ) as any; + } + return Object.assign({}, matchers, { + evaluatedBy: function (evaluator: SimpleEvaluator) { + const mappedMatchers: any = _wrapMatchers( + spreadMatchers, + evaluator, + originalArgs, + originalExpect, + [], + ); + // .not etc. + const staticMatchers = Object.keys(spreadMatchers) + .filter((name) => typeof (matchers as any)[name] !== "function") + .map((name) => { + return { + [name]: Object.assign( + {}, + ..._wrapMatchers( + spreadMatchers, + evaluator, + originalArgs, + originalExpect, + staticPath.concat(name), + ), + ), + }; + }); + return Object.assign({}, ...mappedMatchers, ...staticMatchers); + }, + }); +}; + +export function wrapExpect(originalExpect: any): typeof expect { + // proxy the expect function + const expectProxy = Object.assign( + (...args: any[]) => + addEvaluatedBy(originalExpect(...args), args, originalExpect, []), // partially apply expect to get all matchers and chain them + originalExpect, // clone additional properties on expect + ); + + return expectProxy; +} + +(globalThis as any).expect = wrapExpect((globalThis as any).expect); diff --git a/src/lib/utils/jestlike/vendor/evaluatedBy.ts b/src/lib/utils/jestlike/vendor/evaluatedBy.ts new file mode 100644 index 0000000..dcc2d9c --- /dev/null +++ b/src/lib/utils/jestlike/vendor/evaluatedBy.ts @@ -0,0 +1,129 @@ +import { ROOT, traceable } from "../../../traceable.js"; +import { + testWrapperAsyncLocalStorageInstance, + _logTestFeedback, + trackingEnabled, +} from "../globals.js"; + +import { SimpleEvaluationResult } from "../types.js"; +import { RunTree, RunTreeConfig } from "../../../run_trees.js"; +import { v7 } from "../../uuid/src/index.js"; + +export type SimpleEvaluatorParams = { + inputs: Record; + referenceOutputs: Record; + outputs: Record; +}; + +export type SimpleEvaluator = ( + params: SimpleEvaluatorParams, +) => SimpleEvaluationResult | Promise; + +function isEvaluationResult(x: unknown): x is SimpleEvaluationResult { + return ( + x != null && + typeof x === "object" && + "key" in x && + typeof x.key === "string" && + "score" in x + ); +} + +export function wrapEvaluator< + I, + O extends SimpleEvaluationResult | SimpleEvaluationResult[], +>(evaluator: (input: I) => O | Promise) { + return async ( + input: I, + config?: Partial & { runId?: string }, + ): Promise => { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if (context === undefined || context.currentExample === undefined) { + throw new Error( + [ + `Could not identify current LangSmith context.`, + `Please ensure you are calling this matcher within "ls.test()"`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + let evalRunId = config?.runId ?? config?.id ?? v7(); + let evalResult: O; + if (trackingEnabled(context)) { + const wrappedEvaluator = traceable( + async (_runTree: RunTree, params: I) => { + return evaluator(params); + }, + { + id: evalRunId, + trace_id: evalRunId, + on_end: (runTree) => { + // If tracing with OTEL, setting run id manually does not work. + // Instead get it at the end of the run. + evalRunId = runTree.id; + }, + reference_example_id: context.currentExample.id, + client: context.client, + tracingEnabled: true, + name: evaluator.name ?? "", + project_name: "evaluators", + ...config, + extra: { + ...config?.extra, + ls_otel_root: true, + }, + }, + ); + + evalResult = await wrappedEvaluator(ROOT, input); + } else { + evalResult = await evaluator(input); + } + let normalizedResult; + if (!Array.isArray(evalResult)) { + normalizedResult = [evalResult]; + } else { + normalizedResult = evalResult; + } + for (const result of normalizedResult) { + if (isEvaluationResult(result)) { + _logTestFeedback({ + exampleId: context?.currentExample?.id, + feedback: result, + context, + runTree: context.testRootRunTree, + client: context.client, + sourceRunId: evalRunId, + }); + } + } + return evalResult; + }; +} + +export async function evaluatedBy(outputs: any, evaluator: SimpleEvaluator) { + const context = testWrapperAsyncLocalStorageInstance.getStore(); + if (context === undefined || context.currentExample === undefined) { + throw new Error( + [ + `Could not identify current LangSmith context.`, + `Please ensure you are calling this matcher within "ls.test()"`, + `See this page for more information: https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest`, + ].join("\n"), + ); + } + const wrappedEvaluator = wrapEvaluator(evaluator); + const evalRunId = v7(); + const evalResult = await wrappedEvaluator( + { + inputs: context.currentExample?.inputs ?? {}, + referenceOutputs: context?.currentExample?.outputs ?? {}, + outputs, + }, + { runId: evalRunId }, + ); + if (Array.isArray(evalResult)) { + return evalResult.map((result) => result.score); + } + return evalResult.score; +} diff --git a/src/lib/utils/ls_agent_type.ts b/src/lib/utils/ls_agent_type.ts new file mode 100644 index 0000000..8d77dab --- /dev/null +++ b/src/lib/utils/ls_agent_type.ts @@ -0,0 +1,38 @@ +// Shared primitives for the `ls_agent_type` metadata tag. +// Consumed by wrappers/integrations that stamp or inspect `ls_agent_type` on +// traced runs (wrap_openai, OpenAI Agents SDK, Vercel, future wrappers). +// Keep this module SDK-agnostic: the caller passes in the parent run tree. + +import { isRunTree } from "../run_trees.js"; + +export type LsAgentType = "root" | "middleware" | "subagent" | "compaction"; + +// Non-root tags: preserved against default/structural stamping. +export const NON_ROOT_LS_AGENT_TYPES: ReadonlySet = new Set([ + "middleware", + "subagent", + "compaction", +]); + +// `"root"` at trace root; `undefined` when nested (rely on propagation). +export function resolveDefaultLsAgentType( + parentRunTree?: unknown, +): LsAgentType | undefined { + return isRunTree(parentRunTree) ? undefined : "root"; +} + +// Spread helper: `{ ls_agent_type: tag }` when tag is set, `{}` when undefined. +export function lsAgentTypeMetadata(tag: LsAgentType | undefined): { + ls_agent_type?: LsAgentType; +} { + return tag !== undefined ? { ls_agent_type: tag } : {}; +} + +// Preserves user-supplied ls_agent_type; stamps default only when key is absent. +export function defaultLsAgentTypeMetadata( + existingMetadata: Record, + parentRunTree?: unknown, +): { ls_agent_type?: LsAgentType } { + if ("ls_agent_type" in existingMetadata) return {}; + return lsAgentTypeMetadata(resolveDefaultLsAgentType(parentRunTree)); +} diff --git a/src/lib/utils/messages.ts b/src/lib/utils/messages.ts new file mode 100644 index 0000000..f500162 --- /dev/null +++ b/src/lib/utils/messages.ts @@ -0,0 +1,32 @@ +import { LangChainBaseMessage } from "../schemas.js"; + +export function isLangChainMessage( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + message?: any, +): message is LangChainBaseMessage { + return typeof message?._getType === "function"; +} + +// Add index signature to data object +interface ConvertedData { + content: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [key: string]: any; +} + +export function convertLangChainMessageToExample( + message: LangChainBaseMessage, +) { + const converted: { type: string; data: ConvertedData } = { + type: message._getType(), + data: { content: message.content }, + }; + // Check for presence of keys in additional_kwargs + if ( + message?.additional_kwargs && + Object.keys(message.additional_kwargs).length > 0 + ) { + converted.data.additional_kwargs = { ...message.additional_kwargs }; + } + return converted; +} diff --git a/src/lib/utils/p-queue.ts b/src/lib/utils/p-queue.ts new file mode 100644 index 0000000..78dc2c8 --- /dev/null +++ b/src/lib/utils/p-queue.ts @@ -0,0 +1,8 @@ +import PQueueMod from "p-queue"; + +// Helper to handle both default and named exports of p-queue +export const PQueue = ( + "default" in PQueueMod ? PQueueMod.default : PQueueMod +) as typeof import("p-queue").default; + +export type PQueueType = InstanceType; diff --git a/src/lib/utils/p-retry/index.ts b/src/lib/utils/p-retry/index.ts new file mode 100644 index 0000000..bdfeb88 --- /dev/null +++ b/src/lib/utils/p-retry/index.ts @@ -0,0 +1,267 @@ +/* eslint-disable */ +// @ts-nocheck +// p-retry code vendored to avoid import issues +// Source: https://github.com/sindresorhus/p-retry +import isNetworkError from "../is-network-error/index.js"; + +function validateRetries(retries) { + if (typeof retries === "number") { + if (retries < 0) { + throw new TypeError("Expected `retries` to be a non-negative number."); + } + + if (Number.isNaN(retries)) { + throw new TypeError( + "Expected `retries` to be a valid number or Infinity, got NaN.", + ); + } + } else if (retries !== undefined) { + throw new TypeError("Expected `retries` to be a number or Infinity."); + } +} + +function validateNumberOption( + name, + value, + { min = 0, allowInfinity = false } = {}, +) { + if (value === undefined) { + return; + } + + if (typeof value !== "number" || Number.isNaN(value)) { + throw new TypeError( + `Expected \`${name}\` to be a number${ + allowInfinity ? " or Infinity" : "" + }.`, + ); + } + + if (!allowInfinity && !Number.isFinite(value)) { + throw new TypeError(`Expected \`${name}\` to be a finite number.`); + } + + if (value < min) { + throw new TypeError(`Expected \`${name}\` to be \u2265 ${min}.`); + } +} + +export class AbortError extends Error { + constructor(message) { + super(); + + if (message instanceof Error) { + this.originalError = message; + ({ message } = message); + } else { + this.originalError = new Error(message); + this.originalError.stack = this.stack; + } + + this.name = "AbortError"; + this.message = message; + } +} + +function calculateDelay(retriesConsumed, options) { + const attempt = Math.max(1, retriesConsumed + 1); + const random = options.randomize ? Math.random() + 1 : 1; + + let timeout = Math.round( + random * options.minTimeout * options.factor ** (attempt - 1), + ); + timeout = Math.min(timeout, options.maxTimeout); + + return timeout; +} + +function calculateRemainingTime(start, max) { + if (!Number.isFinite(max)) { + return max; + } + + return max - (performance.now() - start); +} + +async function onAttemptFailure({ + error, + attemptNumber, + retriesConsumed, + startTime, + options, +}) { + const normalizedError = + error instanceof Error + ? error + : new TypeError( + `Non-error was thrown: "${error}". You should only throw errors.`, + ); + + if (normalizedError instanceof AbortError) { + throw normalizedError.originalError; + } + + const retriesLeft = Number.isFinite(options.retries) + ? Math.max(0, options.retries - retriesConsumed) + : options.retries; + + const maxRetryTime = options.maxRetryTime ?? Number.POSITIVE_INFINITY; + + const context = Object.freeze({ + error: normalizedError, + attemptNumber, + retriesLeft, + retriesConsumed, + }); + + await options.onFailedAttempt(context); + + if (calculateRemainingTime(startTime, maxRetryTime) <= 0) { + throw normalizedError; + } + + const consumeRetry = await options.shouldConsumeRetry(context); + + const remainingTime = calculateRemainingTime(startTime, maxRetryTime); + + if (remainingTime <= 0 || retriesLeft <= 0) { + throw normalizedError; + } + + if ( + normalizedError instanceof TypeError && + !isNetworkError(normalizedError) + ) { + if (consumeRetry) { + throw normalizedError; + } + + options.signal?.throwIfAborted(); + return false; + } + + if (!(await options.shouldRetry(context))) { + throw normalizedError; + } + + if (!consumeRetry) { + options.signal?.throwIfAborted(); + return false; + } + + const delayTime = calculateDelay(retriesConsumed, options); + const finalDelay = Math.min(delayTime, remainingTime); + + if (finalDelay > 0) { + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timeoutToken); + options.signal?.removeEventListener("abort", onAbort); + reject(options.signal.reason); + }; + + const timeoutToken = setTimeout(() => { + options.signal?.removeEventListener("abort", onAbort); + resolve(); + }, finalDelay); + + if (options.unref) { + timeoutToken.unref?.(); + } + + options.signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + options.signal?.throwIfAborted(); + + return true; +} + +export default async function pRetry(input, options = {}) { + options = { ...options }; + + validateRetries(options.retries); + + if (Object.hasOwn(options, "forever")) { + throw new Error( + "The `forever` option is no longer supported. For many use-cases, you can set `retries: Infinity` instead.", + ); + } + + options.retries ??= 10; + options.factor ??= 2; + options.minTimeout ??= 1000; + options.maxTimeout ??= Number.POSITIVE_INFINITY; + options.maxRetryTime ??= Number.POSITIVE_INFINITY; + options.randomize ??= false; + options.onFailedAttempt ??= () => {}; + options.shouldRetry ??= () => true; + options.shouldConsumeRetry ??= () => true; + + // Validate numeric options and normalize edge cases + validateNumberOption("factor", options.factor, { + min: 0, + allowInfinity: false, + }); + validateNumberOption("minTimeout", options.minTimeout, { + min: 0, + allowInfinity: false, + }); + validateNumberOption("maxTimeout", options.maxTimeout, { + min: 0, + allowInfinity: true, + }); + validateNumberOption("maxRetryTime", options.maxRetryTime, { + min: 0, + allowInfinity: true, + }); + + // Treat non-positive factor as 1 to avoid zero backoff or negative behavior + if (!(options.factor > 0)) { + options.factor = 1; + } + + options.signal?.throwIfAborted(); + + let attemptNumber = 0; + let retriesConsumed = 0; + const startTime = performance.now(); + + while ( + Number.isFinite(options.retries) ? retriesConsumed <= options.retries : true + ) { + attemptNumber++; + + try { + options.signal?.throwIfAborted(); + + const result = await input(attemptNumber); + + options.signal?.throwIfAborted(); + + return result; + } catch (error) { + if ( + await onAttemptFailure({ + error, + attemptNumber, + retriesConsumed, + startTime, + options, + }) + ) { + retriesConsumed++; + } + } + } + + // Should not reach here, but in case it does, throw an error + throw new Error("Retry attempts exhausted without throwing an error."); +} + +export function makeRetriable(function_, options) { + return function (...arguments_) { + return pRetry(() => function_.apply(this, arguments_), options); + }; +} diff --git a/src/lib/utils/p-retry/license b/src/lib/utils/p-retry/license new file mode 100644 index 0000000..fa7ceba --- /dev/null +++ b/src/lib/utils/p-retry/license @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lib/utils/profile-lock.ts b/src/lib/utils/profile-lock.ts new file mode 100644 index 0000000..b6aba27 --- /dev/null +++ b/src/lib/utils/profile-lock.ts @@ -0,0 +1,125 @@ +import * as fsUtils from "./fs.js"; + +const LOCK_POLL_INTERVAL_MS = 10; +const LOCK_STALE_AFTER_MS = 10_000; +const LOCK_METADATA_FILE = "created_at"; + +export interface OAuthRefreshLock { + release(): Promise; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isEEXIST(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + (err as { code?: string }).code === "EEXIST" + ); +} + +function lockMetadataLines(lockDir: string): string[] | undefined { + try { + return fsUtils + .readFileSync(fsUtils.path.join(lockDir, LOCK_METADATA_FILE)) + .split("\n"); + } catch { + return undefined; + } +} + +function lockCreatedAtMs(lockDir: string): number | undefined { + const lines = lockMetadataLines(lockDir); + if (lines && lines[0] && lines[0].trim()) { + const parsed = Date.parse(lines[0].trim()); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return fsUtils.statMtimeMs(lockDir); +} + +function lockOwner(lockDir: string): string | undefined { + const lines = lockMetadataLines(lockDir); + if (lines && lines.length >= 2 && lines[1].trim()) { + return lines[1].trim(); + } + return undefined; +} + +async function removeStaleLock(lockDir: string): Promise { + const createdAt = lockCreatedAtMs(lockDir); + if ( + createdAt === undefined || + Date.now() - createdAt <= LOCK_STALE_AFTER_MS + ) { + return false; + } + await fsUtils.rmRecursive(lockDir); + return true; +} + +/** + * Acquire an exclusive cross-process lock for refreshing OAuth tokens. + * + * Uses an atomic-`mkdir` directory lock at `.oauth.lock.lock` with a + * stale-break heuristic and owner-checked release, mirroring langsmith-go's + * non-POSIX path. `deadline` is a `Date.now()`-based timestamp; acquisition + * rejects once it passes. Callers treat any rejection as "skip refresh, use the + * current token". + */ +export async function acquireOAuthRefreshLock( + configPath: string, + deadline: number, +): Promise { + const lockDir = `${configPath}.oauth.lock.lock`; + const parent = fsUtils.path.dirname(lockDir); + if (parent) { + await fsUtils.mkdir(parent); + } + const owner = globalThis.crypto.randomUUID(); + for (;;) { + try { + await fsUtils.mkdirExclusive(lockDir); + } catch (err) { + if (!isEEXIST(err)) { + throw err; + } + if (!(await removeStaleLock(lockDir))) { + if (Date.now() >= deadline) { + throw new Error("timed out acquiring OAuth refresh lock"); + } + await sleep( + Math.min(LOCK_POLL_INTERVAL_MS, Math.max(0, deadline - Date.now())), + ); + } + continue; + } + try { + await fsUtils.writeFileAtomic( + fsUtils.path.join(lockDir, LOCK_METADATA_FILE), + `${new Date().toISOString()}\n${owner}\n`, + ); + } catch (err) { + await fsUtils.rmRecursive(lockDir); + throw err; + } + break; + } + return { + async release() { + if (lockOwner(lockDir) === owner) { + await fsUtils.rmRecursive(lockDir); + } + }, + }; +} + +// Exposed for tests only. +export const _internal = { + LOCK_METADATA_FILE, + LOCK_STALE_AFTER_MS, + lockOwner, +}; diff --git a/src/lib/utils/profiles.ts b/src/lib/utils/profiles.ts new file mode 100644 index 0000000..05a141d --- /dev/null +++ b/src/lib/utils/profiles.ts @@ -0,0 +1,512 @@ +import { getEnv, getEnvironmentVariable } from "./env.js"; +import * as fsUtils from "./fs.js"; +import { acquireOAuthRefreshLock } from "./profile-lock.js"; + +export const DEFAULT_API_URL = "https://api.smith.langchain.com"; + +const OAUTH_CLIENT_ID = "langsmith-cli"; +const TOKEN_REFRESH_LEEWAY_MS = 60_000; +const TOKEN_REFRESH_TIMEOUT_MS = 10_000; +const OAUTH_DISCOVERY_TIMEOUT_MS = 5_000; +const WELL_KNOWN_OAUTH_PATH = "/.well-known/oauth-authorization-server"; + +type LangSmithProfileOAuth = { + access_token?: string; + refresh_token?: string; + expires_at?: string; +}; + +type LangSmithProfile = { + api_key?: string; + api_url?: string; + workspace_id?: string; + oauth?: LangSmithProfileOAuth; +}; + +type LangSmithProfileConfigFile = { + current_profile?: string; + profiles?: Record; +}; + +type LangSmithProfileState = { + configPath: string; + config: LangSmithProfileConfigFile; + profileName: string; + profile: LangSmithProfile; +}; + +export type ProfileAuthHeader = { + name: "Authorization" | "x-api-key"; + value: string; +}; + +export type ProfileClientConfig = { + apiUrl?: string; + apiKey?: string; + workspaceId?: string; + oauthAccessToken?: string; + oauthRefreshToken?: string; + profileAuth?: ProfileAuth; +}; + +function isBrowserLikeRuntime(): boolean { + const env = getEnv(); + return env === "browser" || env === "webworker"; +} + +function getProfileConfigPath(): string | undefined { + const explicitPath = getEnvironmentVariable("LANGSMITH_CONFIG_FILE"); + if (explicitPath) { + return explicitPath; + } + const home = + getEnvironmentVariable("HOME") ?? getEnvironmentVariable("USERPROFILE"); + if (!home) { + return undefined; + } + return fsUtils.path.join(home, ".langsmith", "config.json"); +} + +function resolveProfileName( + config: LangSmithProfileConfigFile, +): string | undefined { + const envProfile = getEnvironmentVariable("LANGSMITH_PROFILE"); + if (envProfile) { + return envProfile; + } + if (config.current_profile) { + return config.current_profile; + } + if (config.profiles?.default) { + return "default"; + } + return undefined; +} + +function loadProfileState(): LangSmithProfileState | undefined { + if (isBrowserLikeRuntime()) { + return undefined; + } + const configPath = getProfileConfigPath(); + if (!configPath || !fsUtils.existsSync(configPath)) { + return undefined; + } + try { + const config = JSON.parse( + fsUtils.readFileSync(configPath), + ) as LangSmithProfileConfigFile; + const profileName = resolveProfileName(config); + const profile = profileName ? config.profiles?.[profileName] : undefined; + if (!profileName || !profile) { + return undefined; + } + return { configPath, config, profileName, profile }; + } catch { + return undefined; + } +} + +export function hasValue(value?: string | null): boolean { + return value !== undefined && value !== null && value.trim() !== ""; +} + +function trimConfigValue(value?: string): string | undefined { + return value?.trim().replace(/^["']|["']$/g, ""); +} + +function shouldRefreshProfileToken(profile: LangSmithProfile): boolean { + const oauth = profile.oauth; + if (!oauth?.refresh_token) { + return false; + } + if (!oauth.access_token) { + return true; + } + if (!oauth.expires_at) { + return false; + } + const expiresAt = Date.parse(oauth.expires_at); + if (Number.isNaN(expiresAt)) { + return false; + } + return expiresAt <= Date.now() + TOKEN_REFRESH_LEEWAY_MS; +} + +function normalizeConfigUrl(apiUrl: string): string { + let normalized = apiUrl; + while (normalized.endsWith("/")) { + normalized = normalized.slice(0, -1); + } + const apiV1Suffix = "/api/v1"; + return normalized.endsWith(apiV1Suffix) + ? normalized.slice(0, -apiV1Suffix.length) + : normalized; +} + +type OAuthServerMetadata = { + issuer?: unknown; + device_authorization_endpoint?: unknown; + token_endpoint?: unknown; +}; + +/** + * Metadata base URLs to probe, most specific first: the configured mount point, + * then the self-hosted and SaaS locations, so both a bare origin and an + * explicit `/api` resolve. + */ +function oauthDiscoveryCandidates(apiUrl: string): string[] { + const given = normalizeConfigUrl(apiUrl); + const origin = given.endsWith("/api") + ? given.slice(0, -"/api".length) + : given; + + const candidates: string[] = []; + for (const candidate of [given, `${origin}/api`, origin]) { + if (candidate && candidate !== "/api" && !candidates.includes(candidate)) { + candidates.push(candidate); + } + } + return candidates; +} + +/** + * Check the document describes the deployment we probed. RFC 8414 requires the + * issuer to match the URL the well-known path was built from, and every + * endpoint must share the issuer's origin. Refresh tokens are posted to these + * endpoints, so an unvalidated document could redirect credentials elsewhere. + */ +function isTrustedOAuthMetadata( + doc: OAuthServerMetadata, + base: string, +): boolean { + const { issuer } = doc; + if ( + typeof issuer !== "string" || + issuer.replace(/\/+$/, "") !== base.replace(/\/+$/, "") + ) { + return false; + } + let issuerUrl: URL; + try { + issuerUrl = new URL(issuer); + } catch { + return false; + } + for (const endpoint of [ + doc.device_authorization_endpoint, + doc.token_endpoint, + ]) { + if (typeof endpoint !== "string" || !endpoint) { + return false; + } + try { + const url = new URL(endpoint); + if (url.protocol !== issuerUrl.protocol || url.host !== issuerUrl.host) { + return false; + } + } catch { + return false; + } + } + return true; +} + +/** + * Metadata URLs for an issuer, RFC 8414 form first. RFC 8414 inserts the + * well-known segment between the origin and the issuer path, so + * `https://host/api` is described at + * `https://host/.well-known/oauth-authorization-server/api`. Deployments + * commonly also serve the appended form, and for a path-less issuer the two are + * identical. + */ +function oauthMetadataUrls(base: string): string[] { + const appended = `${base}${WELL_KNOWN_OAUTH_PATH}`; + let inserted: string; + try { + const url = new URL(base); + inserted = `${url.origin}${WELL_KNOWN_OAUTH_PATH}${ + url.pathname === "/" ? "" : url.pathname + }`; + } catch { + return [appended]; + } + return inserted === appended ? [inserted] : [inserted, appended]; +} + +async function fetchOAuthMetadata( + url: string, + base: string, + fetchImplementation: typeof fetch, +): Promise { + let response: Response; + try { + response = await fetchImplementation(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(OAUTH_DISCOVERY_TIMEOUT_MS), + }); + } catch { + return undefined; + } + if (!response.ok) { + return undefined; + } + let doc: OAuthServerMetadata; + try { + // Self-hosted answers unknown paths with an HTML 200 from the SPA. + doc = (await response.json()) as OAuthServerMetadata; + } catch { + return undefined; + } + if (!doc || typeof doc !== "object" || !isTrustedOAuthMetadata(doc, base)) { + return undefined; + } + return doc; +} + +/** + * Resolve the token endpoint, preferring the deployment's metadata document. + * Falls back to `/oauth/token`, keeping a trailing `/api` because + * self-hosted mounts the authorization server under it. + */ +export async function resolveTokenEndpoint( + apiUrl: string, + fetchImplementation: typeof fetch, +): Promise { + for (const base of oauthDiscoveryCandidates(apiUrl)) { + for (const url of oauthMetadataUrls(base)) { + const doc = await fetchOAuthMetadata(url, base, fetchImplementation); + if (doc) { + return doc.token_endpoint as string; + } + } + } + return `${normalizeConfigUrl(apiUrl)}/oauth/token`; +} + +function applyTokenResponse( + profile: LangSmithProfile, + token: { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }, +): void { + profile.oauth ??= {}; + if (token.access_token) { + profile.oauth.access_token = token.access_token; + } + if (token.refresh_token) { + profile.oauth.refresh_token = token.refresh_token; + } + if (typeof token.expires_in === "number" && token.expires_in > 0) { + profile.oauth.expires_at = new Date( + Date.now() + token.expires_in * 1000, + ).toISOString(); + } +} + +function getAbortReason(signal: AbortSignal): unknown { + return ( + (signal as AbortSignal & { reason?: unknown }).reason ?? + new Error("The operation was aborted.") + ); +} + +async function waitForAbortSignal( + promise: Promise, + signal?: AbortSignal | null, +): Promise { + if (!signal) { + return promise; + } + if (signal.aborted) { + throw getAbortReason(signal); + } + let cleanup: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + const onAbort = () => { + reject(getAbortReason(signal)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + cleanup = () => { + signal.removeEventListener("abort", onAbort); + }; + }); + try { + return await Promise.race([promise, abortPromise]); + } finally { + cleanup?.(); + } +} + +export function loadProfileClientConfig(): ProfileClientConfig { + const state = loadProfileState(); + const profile = state?.profile; + if (!state || !profile) { + return {}; + } + const apiKey = trimConfigValue(profile.api_key); + const oauthAccessToken = trimConfigValue(profile.oauth?.access_token); + const oauthRefreshToken = trimConfigValue(profile.oauth?.refresh_token); + return { + apiUrl: profile.api_url, + apiKey, + workspaceId: profile.workspace_id, + oauthAccessToken, + oauthRefreshToken, + profileAuth: + apiKey || oauthAccessToken || oauthRefreshToken + ? new ProfileAuth(state) + : undefined, + }; +} + +export class ProfileAuth { + private refreshPromise?: Promise; + + private managedAuthorizationValue?: string; + + constructor(private state: LangSmithProfileState) { + this.rememberProfileAuthHeader(this.currentAuthHeader()); + } + + currentAuthHeader(): ProfileAuthHeader | undefined { + const header = currentAuthHeaderFromProfile(this.state.profile); + this.rememberProfileAuthHeader(header); + return header; + } + + async getAuthHeader( + fetchImplementation: typeof fetch, + signal?: AbortSignal | null, + ): Promise { + if (shouldRefreshProfileToken(this.state.profile)) { + if (!this.refreshPromise) { + this.refreshPromise = this.refreshOAuthToken( + fetchImplementation, + ).finally(() => { + this.refreshPromise = undefined; + }); + } + await waitForAbortSignal(this.refreshPromise, signal); + } + const header = authHeaderFromProfile(this.state.profile); + this.rememberProfileAuthHeader(header); + return header; + } + + isProfileAuthorizationHeader(value: string): boolean { + return value === this.managedAuthorizationValue; + } + + private reloadProfile(): LangSmithProfile | undefined { + try { + const config = JSON.parse( + fsUtils.readFileSync(this.state.configPath), + ) as LangSmithProfileConfigFile; + const profile = config.profiles?.[this.state.profileName]; + if (!profile) { + return undefined; + } + this.state.config = config; + this.state.profile = profile; + return profile; + } catch { + return undefined; + } + } + + private async refreshOAuthToken( + fetchImplementation: typeof fetch, + ): Promise { + const refreshToken = this.state.profile.oauth?.refresh_token; + if (!refreshToken) { + return; + } + const refreshApiUrl = + trimConfigValue(this.state.profile.api_url) ?? DEFAULT_API_URL; + const deadline = Date.now() + TOKEN_REFRESH_TIMEOUT_MS; + let lock: { release(): Promise } | undefined; + try { + lock = await acquireOAuthRefreshLock(this.state.configPath, deadline); + const fresh = this.reloadProfile(); + if (fresh && !shouldRefreshProfileToken(this.state.profile)) { + return; + } + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: OAUTH_CLIENT_ID, + refresh_token: this.state.profile.oauth?.refresh_token ?? refreshToken, + }); + const tokenEndpoint = await resolveTokenEndpoint( + refreshApiUrl, + fetchImplementation, + ); + const response = await fetchImplementation(tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + signal: AbortSignal.timeout(Math.max(0, deadline - Date.now())), + }); + if (!response.ok) { + return; + } + const token = (await response.json()) as { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + if (!token.access_token) { + return; + } + applyTokenResponse(this.state.profile, token); + this.state.config.profiles ??= {}; + this.state.config.profiles[this.state.profileName] = this.state.profile; + await fsUtils.writeFileAtomic( + this.state.configPath, + `${JSON.stringify(this.state.config, null, 2)}\n`, + ); + } catch { + return; + } finally { + await lock?.release(); + } + } + + private rememberProfileAuthHeader( + header: ProfileAuthHeader | undefined, + ): void { + this.managedAuthorizationValue = + header?.name === "Authorization" ? header.value : undefined; + } +} + +function currentAuthHeaderFromProfile( + profile: LangSmithProfile, +): ProfileAuthHeader | undefined { + const oauthAccessToken = trimConfigValue(profile.oauth?.access_token); + if (oauthAccessToken) { + return { name: "Authorization", value: `Bearer ${oauthAccessToken}` }; + } + if (trimConfigValue(profile.oauth?.refresh_token)) { + return undefined; + } + return authHeaderFromProfile(profile); +} + +function authHeaderFromProfile( + profile: LangSmithProfile, +): ProfileAuthHeader | undefined { + const oauthAccessToken = trimConfigValue(profile.oauth?.access_token); + if (oauthAccessToken) { + return { name: "Authorization", value: `Bearer ${oauthAccessToken}` }; + } + const apiKey = trimConfigValue(profile.api_key); + if (apiKey) { + return { name: "x-api-key", value: apiKey }; + } + return undefined; +} diff --git a/src/lib/utils/project.ts b/src/lib/utils/project.ts new file mode 100644 index 0000000..cbacfe8 --- /dev/null +++ b/src/lib/utils/project.ts @@ -0,0 +1,12 @@ +import { + getEnvironmentVariable, + getLangSmithEnvironmentVariable, +} from "./env.js"; + +export const getDefaultProjectName = () => { + return ( + getLangSmithEnvironmentVariable("PROJECT") ?? + getEnvironmentVariable("LANGCHAIN_SESSION") ?? // TODO: Deprecate + "default" + ); +}; diff --git a/src/lib/utils/prompt_cache/index.ts b/src/lib/utils/prompt_cache/index.ts new file mode 100644 index 0000000..4b7608b --- /dev/null +++ b/src/lib/utils/prompt_cache/index.ts @@ -0,0 +1,407 @@ +/** + * Prompt caching module for LangSmith SDK. + * + * Provides an LRU cache with background refresh for prompt caching. + * Uses stale-while-revalidate pattern for optimal performance. + * + * Works in all environments. File operations (dump/load) use the shared + * fs abstraction which is swapped for browser builds via package.json + * browser field (no-ops in browser — cache just doesn't persist). + */ + +import type { PromptCommit } from "../../schemas.js"; +import { + path, + existsSync, + mkdirSync, + writeFileSync, + renameSync, + unlinkSync, + readFileSync, +} from "../fs.js"; + +/** + * A single cache entry with metadata for TTL tracking. + */ +export interface CacheEntry { + value: T; + createdAt: number; // Date.now() when entry was created/refreshed + refreshFunc?: () => Promise; +} + +/** + * Cache performance metrics. + */ +export interface CacheMetrics { + hits: number; + misses: number; + refreshes: number; + refreshErrors: number; +} + +/** + * Configuration options for Cache. + */ +export interface CacheConfig { + /** Maximum entries in cache (LRU eviction when exceeded). Default: 100 */ + maxSize?: number; + /** Time in seconds before entry is stale. null = infinite TTL. Default: 3600 */ + ttlSeconds?: number | null; + /** How often to check for stale entries in seconds. Default: 60 */ + refreshIntervalSeconds?: number; +} + +/** + * Check if a cache entry is stale based on TTL. + */ +function isStale(entry: CacheEntry, ttlSeconds: number | null): boolean { + if (ttlSeconds === null) { + return false; // Infinite TTL, never stale + } + const ageMs = Date.now() - entry.createdAt; + return ageMs > ttlSeconds * 1000; +} + +/** + * LRU cache with background refresh for prompts. + * + * Features: + * - In-memory LRU cache with configurable max size + * - Background refresh using setInterval + * - Stale-while-revalidate: returns stale data while refresh happens + * - Uses the most recently used client for a key for refreshes + * - JSON dump/load for offline use + * + * @example + * ```typescript + * const cache = new Cache({ + * maxSize: 100, + * ttlSeconds: 3600, + * }); + * + * // Use the cache + * cache.set("my-prompt:latest", promptCommit); + * const cached = cache.get("my-prompt:latest"); + * + * // Cleanup + * cache.stop(); + * ``` + */ +export class PromptCache { + private cache: Map> = new Map(); + private maxSize: number; + private ttlSeconds: number | null; + private refreshIntervalSeconds: number; + private refreshTimer?: ReturnType; + private _metrics: CacheMetrics = { + hits: 0, + misses: 0, + refreshes: 0, + refreshErrors: 0, + }; + + constructor(config: CacheConfig = {}) { + this.configure(config); + } + + /** + * Get cache performance metrics. + */ + get metrics(): Readonly { + return { ...this._metrics }; + } + + /** + * Get total cache requests (hits + misses). + */ + get totalRequests(): number { + return this._metrics.hits + this._metrics.misses; + } + + /** + * Get cache hit rate (0.0 to 1.0). + */ + get hitRate(): number { + const total = this.totalRequests; + return total > 0 ? this._metrics.hits / total : 0; + } + + /** + * Reset all metrics to zero. + */ + resetMetrics(): void { + this._metrics = { + hits: 0, + misses: 0, + refreshes: 0, + refreshErrors: 0, + }; + } + + /** + * Get a value from cache. + * + * Returns the cached value or undefined if not found. + * Stale entries are still returned (background refresh handles updates). + */ + get( + key: string, + refreshFunc: () => Promise, + ): PromptCommit | undefined { + // If max_size is 0, cache is disabled + if (this.maxSize === 0) { + return undefined; + } + + const entry = this.cache.get(key); + if (!entry) { + this._metrics.misses += 1; + return undefined; + } + + // Move to end for LRU (delete and re-add) + this.cache.delete(key); + this.cache.set(key, { ...entry, refreshFunc }); + + this._metrics.hits += 1; + return entry.value; + } + + /** + * Set a value in the cache. + */ + set( + key: string, + value: PromptCommit, + refreshFunc: () => Promise, + ): void { + // If max_size is 0, cache is disabled - do nothing + if (this.maxSize === 0) { + return; + } + + if (this.refreshTimer === undefined) { + this.startRefreshLoop(); + } + // Check if we need to evict (and key is new) + if (!this.cache.has(key) && this.cache.size >= this.maxSize) { + // Evict oldest (first item in Map) + const oldestKey = this.cache.keys().next().value; + if (oldestKey !== undefined) { + this.cache.delete(oldestKey); + } + } + + const entry: CacheEntry = { + value, + createdAt: Date.now(), + refreshFunc, + }; + + // Delete first to ensure it's at the end + this.cache.delete(key); + this.cache.set(key, entry); + } + + /** + * Remove a specific entry from cache. + */ + invalidate(key: string): void { + this.cache.delete(key); + } + + /** + * Clear all cache entries. + */ + clear(): void { + this.cache.clear(); + } + + /** + * Get the number of entries in the cache. + */ + get size(): number { + return this.cache.size; + } + + /** + * Stop background refresh. + * Should be called when the client is being cleaned up. + */ + stop(): void { + if (this.refreshTimer) { + clearInterval(this.refreshTimer); + this.refreshTimer = undefined; + } + } + + /** + * Dump cache contents to a JSON file for offline use. + */ + dump(filePath: string): void { + const entries: Record = {}; + for (const [key, entry] of this.cache.entries()) { + entries[key] = entry.value; + } + + const dir = path.dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir); + } + const tempPath = `${filePath}.tmp`; + try { + writeFileSync(tempPath, JSON.stringify({ entries }, null, 2)); + renameSync(tempPath, filePath); + } catch (e) { + if (existsSync(tempPath)) { + unlinkSync(tempPath); + } + throw e; + } + } + + /** + * Load cache contents from a JSON file. + * + * Loaded entries get a fresh TTL starting from load time. + * + * @returns Number of entries loaded. + */ + load(filePath: string): number { + if (!existsSync(filePath)) { + return 0; + } + let entries: Record | null; + try { + const content = readFileSync(filePath); + const data = JSON.parse(content); + entries = data.entries ?? null; + } catch { + return 0; + } + if (!entries) { + return 0; + } + + let loaded = 0; + const now = Date.now(); + + for (const [key, value] of Object.entries(entries)) { + if (this.cache.size >= this.maxSize) { + break; + } + + const entry: CacheEntry = { + value: value as PromptCommit, + createdAt: now, // Fresh TTL from load time + }; + this.cache.set(key, entry); + loaded += 1; + } + + return loaded; + } + + /** + * Start the background refresh loop. + */ + startRefreshLoop(): void { + this.stop(); + if (this.ttlSeconds !== null) { + this.refreshTimer = setInterval(() => { + this.refreshStaleEntries().catch((e) => { + // Log but don't die - keep the refresh loop running + console.warn("Unexpected error in cache refresh loop:", e); + }); + }, this.refreshIntervalSeconds * 1000); + + // Don't block Node.js from exiting + if (this.refreshTimer.unref) { + this.refreshTimer.unref(); + } + } + } + + /** + * Get list of stale cache keys. + */ + private getStaleEntries(): [string, CacheEntry][] { + const staleEntries: [string, CacheEntry][] = []; + for (const [key, value] of this.cache.entries()) { + if (isStale(value, this.ttlSeconds)) { + staleEntries.push([key, value]); + } + } + return staleEntries; + } + + /** + * Check for stale entries and refresh them. + */ + private async refreshStaleEntries(): Promise { + const staleEntries = this.getStaleEntries(); + if (staleEntries.length === 0) { + return; + } + + for (const [key, value] of staleEntries) { + if (value.refreshFunc !== undefined) { + try { + const newValue = await value.refreshFunc(); + this.set(key, newValue, value.refreshFunc); + this._metrics.refreshes += 1; + } catch (e) { + // Keep stale data on refresh failure + this._metrics.refreshErrors += 1; + console.warn(`Failed to refresh cache entry ${key}:`, e); + } + } + } + } + + configure(config: CacheConfig): void { + this.stop(); + this.refreshIntervalSeconds = config.refreshIntervalSeconds ?? 60; + this.maxSize = config.maxSize ?? 100; + this.ttlSeconds = config.ttlSeconds ?? 5 * 60; + } +} + +/** + * Global singleton instance of PromptCache. + * Use configureGlobalPromptCache(), enableGlobalPromptCache(), or disableGlobalPromptCache() instead. + */ +export const promptCacheSingleton = new PromptCache(); + +/** + * Configure the global prompt cache. + * + * This should be called before any cache instances are created. + * + * @param config - Cache configuration options + * + * @example + * ```typescript + * import { configureGlobalPromptCache } from 'langsmith'; + * + * configureGlobalPromptCache({ maxSize: 200, ttlSeconds: 7200 }); + * ``` + */ +export function configureGlobalPromptCache(config: CacheConfig): void { + promptCacheSingleton.configure(config); +} + +/** + * @deprecated Use `PromptCache` instead. This is a deprecated alias. + * + * Deprecated alias for PromptCache. Use PromptCache instead. + */ +export class Cache extends PromptCache { + constructor(config: CacheConfig = {}) { + console.warn( + "The 'Cache' class is deprecated and will be removed in a future version. " + + "Use 'PromptCache' instead.", + ); + super(config); + } +} diff --git a/src/lib/utils/prompts.ts b/src/lib/utils/prompts.ts new file mode 100644 index 0000000..066391d --- /dev/null +++ b/src/lib/utils/prompts.ts @@ -0,0 +1,36 @@ +import { getInvalidPromptIdentifierMsg } from "./error.js"; + +/** + * Parse a hub repo identifier (owner/name:hash, name, etc.). + * + * Prompts, agents, and skills share the same identifier grammar on Hub. + */ +export function parseHubIdentifier( + identifier: string, +): [string, string, string] { + if ( + !identifier || + identifier.split("/").length > 2 || + identifier.startsWith("/") || + identifier.endsWith("/") || + identifier.split(":").length > 2 + ) { + throw new Error(getInvalidPromptIdentifierMsg(identifier)); + } + + const [ownerNamePart, commitPart] = identifier.split(":"); + const commit = commitPart || "latest"; + + if (ownerNamePart.includes("/")) { + const [owner, name] = ownerNamePart.split("/", 2); + if (!owner || !name) { + throw new Error(getInvalidPromptIdentifierMsg(identifier)); + } + return [owner, name, commit]; + } else { + if (!ownerNamePart) { + throw new Error(getInvalidPromptIdentifierMsg(identifier)); + } + return ["-", ownerNamePart, commit]; + } +} diff --git a/src/lib/utils/redaction.ts b/src/lib/utils/redaction.ts new file mode 100644 index 0000000..9432a68 --- /dev/null +++ b/src/lib/utils/redaction.ts @@ -0,0 +1,49 @@ +import { SECRET_PLACEHOLDER } from "../anonymizer/index.js"; +import type { KVMap } from "../schemas.js"; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Mask a value, keeping object key names so a trace shows what was set. */ +export function mask(value: unknown): unknown { + if (isPlainObject(value)) { + return Object.fromEntries( + Object.keys(value).map((key) => [key, SECRET_PLACEHOLDER]), + ); + } + return SECRET_PLACEHOLDER; +} + +/** Copy `entry`, masking values outside `safeKeys`, so unknown keys fail closed. */ +export function redactOutside( + entry: unknown, + safeKeys: ReadonlySet, +): unknown { + if (!isPlainObject(entry)) return entry; + return Object.fromEntries( + Object.entries(entry).map(([key, value]) => [ + key, + safeKeys.has(key) ? value : SECRET_PLACEHOLDER, + ]), + ); +} + +/** + * Apply `redact` to every object argument, since `traceable` shapes a + * multi-argument call as `{ args: [params, requestOptions] }`. + */ +export function overParams( + inputs: KVMap, + redact: (params: KVMap) => KVMap, +): KVMap { + if (Array.isArray(inputs?.args)) { + return { + ...inputs, + args: inputs.args.map((arg) => + isPlainObject(arg) ? redact(arg as KVMap) : arg, + ), + }; + } + return redact(inputs); +} diff --git a/src/lib/utils/sampling.ts b/src/lib/utils/sampling.ts new file mode 100644 index 0000000..9f162b2 --- /dev/null +++ b/src/lib/utils/sampling.ts @@ -0,0 +1,38 @@ +/** + * Deterministic trace sampling, kept consistent across LangSmith SDKs. + */ + +import { XXH3_128 } from "./xxhash/xxhash.js"; + +const SAMPLING_HASH_MODULUS = 1_000_000n; + +// Reused across calls: constructing a TextEncoder per hash costs more than the +// hash itself. +const encoder = new TextEncoder(); + +/** + * Decide whether `identifier` is sampled in at `samplingRate`. + * + * The decision is a pure function of the identifier, so every process and + * every SDK agrees on it, and a run's create and patch never disagree. + */ +export const isSampledById = ( + identifier: string | null | undefined, + samplingRate: number | undefined, +): boolean => { + if (samplingRate === undefined || samplingRate >= 1) { + return true; + } + if (samplingRate <= 0) { + return false; + } + if (identifier == null) { + return true; + } + // XXH3-128 over the UTF-8 bytes, matching the Python SDK's `xxhash.xxh3_128`. + // The identifier is sampled in when its fraction of the modulus falls below + // the rate, which is also expressed in [0, 1). + const bucket = + XXH3_128(encoder.encode(identifier.toLowerCase())) % SAMPLING_HASH_MODULUS; + return Number(bucket) / Number(SAMPLING_HASH_MODULUS) < samplingRate; +}; diff --git a/src/lib/utils/serialize_worker.ts b/src/lib/utils/serialize_worker.ts new file mode 100644 index 0000000..e040fd7 --- /dev/null +++ b/src/lib/utils/serialize_worker.ts @@ -0,0 +1,374 @@ +/** + * Off-thread serialization using Node worker_threads. + * + * Falls back silently to synchronous serialize() when: + * - worker_threads is unavailable (browsers, Deno, Bun without compat, + * Cloudflare Workers, Vercel Edge, React Native) + * - the worker cannot be constructed (bundler/runtime constraints) + * - DataCloneError is thrown for a payload containing non-cloneable + * values (functions, class instances with non-cloneable state, etc.) + * - the worker crashes or throws + * + * Protocol: + * main -> worker: { id, op, payload } + * op = "serialize" -> worker returns bytes as a transferable ArrayBuffer + * worker -> main: { id, bytes?: ArrayBuffer, error?: string } + * + * The worker source is inlined as a string so the library bundles cleanly + * under webpack/esbuild/ncc without requiring a separate asset file. + */ + +import { + Worker as WorkerCtor, + WORKER_THREADS_AVAILABLE, + type WorkerLike, +} from "./worker_threads.js"; + +// The worker script: a self-contained mirror of the hot path of +// src/utils/fast-safe-stringify/index.ts#serialize(). We deliberately +// don't import the TS module -- the worker runs as a standalone script. +const WORKER_SOURCE = /* js */ ` +const { parentPort } = require("worker_threads"); + +const CIRCULAR_REPLACE_NODE = { result: "[Circular]" }; + +function serializeWellKnownTypes(val) { + if (val && typeof val === "object") { + if (val instanceof Map) return Object.fromEntries(val); + if (val instanceof Set) return Array.from(val); + if (val instanceof Date) return val.toISOString(); + if (val instanceof RegExp) return val.toString(); + if (val instanceof Error) return { name: val.name, message: val.message }; + } else if (typeof val === "bigint") { + return val.toString(); + } + return val; +} + +function defaultReplacer(_key, val) { + return serializeWellKnownTypes(val); +} + +// Decirculate in-place: replace circular refs with { result: "[Circular]" } +// then restore after stringify. Mirrors fast-safe-stringify's decirc(). +const restoreStack = []; +function decirc(val, k, stack, parent) { + if (typeof val === "object" && val !== null) { + for (let i = 0; i < stack.length; i++) { + if (stack[i] === val) { + const orig = parent[k]; + parent[k] = CIRCULAR_REPLACE_NODE; + restoreStack.push([parent, k, orig]); + return; + } + } + stack.push(val); + if (Array.isArray(val)) { + for (let i = 0; i < val.length; i++) decirc(val[i], i, stack, val); + } else { + const normalized = serializeWellKnownTypes(val); + // Only recurse into normalized if it's still an object (arrays/objects), + // else it was replaced with a primitive (e.g. Date -> string). + if (normalized === val) { + const keys = Object.keys(val); + for (let i = 0; i < keys.length; i++) decirc(val[keys[i]], keys[i], stack, val); + } + } + stack.pop(); + } +} + +function serialize(obj) { + try { + return JSON.stringify(obj, defaultReplacer); + } catch (e) { + if (!String(e && e.message).includes("Converting circular structure to JSON")) { + return "[Unserializable]"; + } + decirc(obj, "", [], { "": obj }); + try { + return JSON.stringify(obj, defaultReplacer); + } catch (_) { + return "[unable to serialize, circular reference is too complex to analyze]"; + } finally { + while (restoreStack.length) { + const [p, k, v] = restoreStack.pop(); + p[k] = v; + } + } + } +} + +parentPort.on("message", (msg) => { + const { id, op, payload } = msg; + try { + if (op === "serialize") { + const str = serialize(payload); + const buf = Buffer.from(str, "utf8"); + // Slice into its own ArrayBuffer so we can transfer without dragging + // unrelated bytes from any shared pool buffer. + const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + parentPort.postMessage({ id, bytes: ab, length: buf.byteLength }, [ab]); + } else if (op === "ping") { + parentPort.postMessage({ id }); + } else { + parentPort.postMessage({ id, error: "unknown op: " + op }); + } + } catch (e) { + parentPort.postMessage({ id, error: String((e && e.message) || e) }); + } +}); +`; + +type Pending = { + resolve: (bytes: Uint8Array) => void; + reject: (err: Error) => void; +}; + +export class SerializeWorker { + private worker: WorkerLike | null = null; + private nextId = 1; + private pending = new Map(); + private disabled = false; + private startPromise: Promise | null = null; + + /** + * Try to construct the worker. Returns false if the runtime can't support + * it -- in that case callers must fall back to synchronous serialization. + * Kept async so callers don't have to branch on runtime -- the promise + * resolves synchronously on the microtask queue when the worker module + * is available, which is the common Node CJS/ESM path. + */ + private async ensureStarted(): Promise { + if (this.disabled) return false; + if (this.worker !== null) return true; + if (this.startPromise !== null) return this.startPromise; + this.startPromise = this._start(); + try { + return await this.startPromise; + } finally { + this.startPromise = null; + } + } + + private async _start(): Promise { + // In browser / edge builds the `worker_threads` module is swapped with + // a stub that reports unavailability via the package.json `browser` + // field. Bail out before touching any Node-only surface. + if (!WORKER_THREADS_AVAILABLE || WorkerCtor === null) { + this.disabled = true; + return false; + } + try { + const worker = new WorkerCtor(WORKER_SOURCE, { eval: true }); + worker.on( + "message", + (msg: { + id: number; + bytes?: ArrayBuffer; + length?: number; + error?: string; + }) => { + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + if (msg.error) { + p.reject(new Error(msg.error)); + } else if (msg.bytes && typeof msg.length === "number") { + p.resolve( + new Uint8Array( + msg.bytes, + 0, + msg.length, + ) as Uint8Array, + ); + } else { + p.reject(new Error("worker returned malformed message")); + } + }, + ); + worker.on("error", (err: Error) => { + // Reject all pending and disable; caller will fall back. + for (const [, p] of this.pending) p.reject(err); + this.pending.clear(); + this.disabled = true; + this.worker = null; + }); + worker.on("exit", (code: number) => { + // Reject all pending requests regardless of exit code. Even a clean + // exit (code 0) with in-flight requests means those promises would + // otherwise hang forever. + for (const [, p] of this.pending) { + p.reject(new Error(`worker exited with code ${code}`)); + } + this.pending.clear(); + this.worker = null; + }); + // Don't let the worker keep the process alive. + worker.unref(); + this.worker = worker; + return true; + } catch { + this.disabled = true; + return false; + } + } + + /** + * Serialize a payload off-thread. Rejects with DataCloneError (or similar) + * if the payload contains non-cloneable values -- callers must catch and + * fall back to synchronous serialize(). + * + * Resolves with null if the worker subsystem is unavailable entirely, + * so the caller can fall back without paying try/catch overhead. + */ + async serialize(payload: unknown): Promise | null> { + const ok = await this.ensureStarted(); + if (!ok) return null; + const id = this.nextId++; + return new Promise>((resolve, reject) => { + this.pending.set(id, { resolve, reject }); + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this.worker as any).postMessage({ id, op: "serialize", payload }); + } catch (e) { + // postMessage throws synchronously for DataCloneError, unclonable + // values, detached buffers, etc. + this.pending.delete(id); + reject(e as Error); + } + }); + } + + async terminate(): Promise { + if (this.worker) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (this.worker as any).terminate(); + this.worker = null; + } + for (const [, p] of this.pending) { + p.reject(new Error("worker terminated")); + } + this.pending.clear(); + } +} + +let sharedWorker: SerializeWorker | null = null; + +/** + * Process-wide shared worker. One worker serves all Client instances to + * avoid spawning multiple threads per process. + */ +export function getSharedSerializeWorker(): SerializeWorker { + if (sharedWorker === null) sharedWorker = new SerializeWorker(); + return sharedWorker; +} + +/** + * Minimum string length (in UTF-16 code units) that justifies the overhead + * of dispatching serialization to a worker thread. + * + * Rationale: V8's postMessage / structuredClone fast-paths large strings + * across isolates by refcounting their underlying storage rather than + * copying the bytes. This makes worker offload a big win for payloads + * dominated by a handful of multi-hundred-KB strings (the classic case is + * base64-encoded images or audio in LLM messages), but a net loss for + * payloads whose bulk is structural -- thousands of keys, deep nesting, + * many small strings -- because every object node must still be walked + * and cloned. + * + * 64KB sits comfortably above typical "chunk of agent state" or "long + * prompt" values (a few KB) and below typical base64 media payloads + * (hundreds of KB to several MB). + */ +const LARGE_STRING_THRESHOLD = 64 * 1024; + +/** + * Maximum number of nodes to inspect before giving up and assuming the + * payload is not worth offloading. Prevents the check itself from becoming + * expensive on pathologically structural payloads (many thousands of small + * keys / array elements). + * + * When the budget is exhausted without finding a large string we return + * false (do not offload). This is the conservative choice: such payloads + * are structural by nature and worker offload empirically regresses them. + */ +const NODE_BUDGET = 2048; + +/** + * Cheap, short-circuiting walk that returns true iff the payload contains + * at least one string of length >= threshold anywhere in its graph. + * + * - Terminates immediately on the first qualifying string. + * - Caps total nodes visited at `nodeBudget` so cost is bounded for huge + * structural payloads. + * - Avoids allocation in the common path: uses an array as a stack and a + * Set only for cycle detection. + * - Uses `string.length` (UTF-16 units), not UTF-8 byte length, because + * that's what V8's string-sharing fast path keys on and because it's + * an O(1) property access. For ASCII content this is identical to the + * UTF-8 byte count; for non-ASCII text the two differ by at most 4x, + * well within the safety margin of the threshold. + */ +export function hasLargeString( + value: unknown, + threshold: number = LARGE_STRING_THRESHOLD, + nodeBudget: number = NODE_BUDGET, +): boolean { + if (value === null || typeof value !== "object") { + return typeof value === "string" && value.length >= threshold; + } + const stack: unknown[] = [value]; + const seen = new Set(); + let visited = 0; + while (stack.length > 0) { + if (visited++ >= nodeBudget) return false; + const cur = stack.pop(); + if (cur === null || cur === undefined) continue; + const t = typeof cur; + if (t === "string") { + if ((cur as string).length >= threshold) return true; + continue; + } + if (t !== "object") continue; + const obj = cur as object; + if (seen.has(obj)) continue; + seen.add(obj); + // Skip well-known opaque types -- none of their enumerable own + // properties produce large strings in practice, and ArrayBuffer views + // would inflate the node budget if iterated element by element. + if ( + obj instanceof Date || + obj instanceof RegExp || + obj instanceof Error || + obj instanceof ArrayBuffer || + ArrayBuffer.isView(obj) + ) { + continue; + } + if (Array.isArray(obj)) { + // Iterate in reverse so the first element is popped first (stable + // left-to-right discovery order, harmless but nice for predictable + // short-circuits in tests). + for (let i = obj.length - 1; i >= 0; i--) stack.push(obj[i]); + continue; + } + if (obj instanceof Map) { + for (const [, v] of obj) stack.push(v); + continue; + } + if (obj instanceof Set) { + for (const v of obj) stack.push(v); + continue; + } + // Push keys in reverse so they pop in declared order. Combined with + // the similar reverse-push for arrays above, this makes discovery + // order a stable depth-first walk in source order -- which matters + // for predictable short-circuit behavior under a node budget. + const keys = Object.keys(obj); + for (let i = keys.length - 1; i >= 0; i--) { + stack.push((obj as Record)[keys[i]]); + } + } + return false; +} diff --git a/src/lib/utils/shuffle.ts b/src/lib/utils/shuffle.ts new file mode 100644 index 0000000..f31ac3f --- /dev/null +++ b/src/lib/utils/shuffle.ts @@ -0,0 +1,13 @@ +export function shuffle(array: T): T { + let currentIndex = array.length; + while (currentIndex !== 0) { + const randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex -= 1; + + const tmp = array[currentIndex]; + array[currentIndex] = array[randomIndex]; + array[randomIndex] = tmp; + } + + return array; +} diff --git a/src/lib/utils/simple-wcswidth/LICENSE b/src/lib/utils/simple-wcswidth/LICENSE new file mode 100644 index 0000000..5875115 --- /dev/null +++ b/src/lib/utils/simple-wcswidth/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Nahiyan Kamal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/lib/utils/simple-wcswidth/index.ts b/src/lib/utils/simple-wcswidth/index.ts new file mode 100644 index 0000000..465542b --- /dev/null +++ b/src/lib/utils/simple-wcswidth/index.ts @@ -0,0 +1,4 @@ +import wcswidth from './src/wcswidth.js'; +import wcwidth from './src/wcwidth.js'; + +export { wcwidth, wcswidth }; diff --git a/src/lib/utils/simple-wcswidth/src/binary-search.ts b/src/lib/utils/simple-wcswidth/src/binary-search.ts new file mode 100644 index 0000000..1ed6898 --- /dev/null +++ b/src/lib/utils/simple-wcswidth/src/binary-search.ts @@ -0,0 +1,29 @@ +import { Interval } from './models.js'; + +/* auxiliary function for binary search in interval table */ +const bisearch = ( + ucs: number, + table: Interval[], + tableSize: number +): number => { + let min = 0; + let mid: number; + let max = tableSize; + + if (ucs < table[0].first || ucs > table[max].last) return 0; + + while (max >= min) { + mid = Math.floor((min + max) / 2); + if (ucs > table[mid].last) { + min = mid + 1; + } else if (ucs < table[mid].first) { + max = mid - 1; + } else { + return 1; + } + } + + return 0; +}; + +export default bisearch; diff --git a/src/lib/utils/simple-wcswidth/src/models.ts b/src/lib/utils/simple-wcswidth/src/models.ts new file mode 100644 index 0000000..daf25b8 --- /dev/null +++ b/src/lib/utils/simple-wcswidth/src/models.ts @@ -0,0 +1,4 @@ +export interface Interval { + first: number; + last: number; +} diff --git a/src/lib/utils/simple-wcswidth/src/non-spacing-chars.ts b/src/lib/utils/simple-wcswidth/src/non-spacing-chars.ts new file mode 100644 index 0000000..973cb8c --- /dev/null +++ b/src/lib/utils/simple-wcswidth/src/non-spacing-chars.ts @@ -0,0 +1,150 @@ +import { Interval } from './models.js'; + +/* sorted list of non-overlapping intervals of non-spacing characters */ +/* generated by "uniset +cat=Me +cat=Mn +cat=Cf -00AD +1160-11FF +200B c" */ +const combining: Interval[] = [ + { first: 0x0300, last: 0x036f }, + { first: 0x0483, last: 0x0486 }, + { first: 0x0488, last: 0x0489 }, + { first: 0x0591, last: 0x05bd }, + { first: 0x05bf, last: 0x05bf }, + { first: 0x05c1, last: 0x05c2 }, + { first: 0x05c4, last: 0x05c5 }, + { first: 0x05c7, last: 0x05c7 }, + { first: 0x0600, last: 0x0603 }, + { first: 0x0610, last: 0x0615 }, + { first: 0x064b, last: 0x065e }, + { first: 0x0670, last: 0x0670 }, + { first: 0x06d6, last: 0x06e4 }, + { first: 0x06e7, last: 0x06e8 }, + { first: 0x06ea, last: 0x06ed }, + { first: 0x070f, last: 0x070f }, + { first: 0x0711, last: 0x0711 }, + { first: 0x0730, last: 0x074a }, + { first: 0x07a6, last: 0x07b0 }, + { first: 0x07eb, last: 0x07f3 }, + { first: 0x0901, last: 0x0902 }, + { first: 0x093c, last: 0x093c }, + { first: 0x0941, last: 0x0948 }, + { first: 0x094d, last: 0x094d }, + { first: 0x0951, last: 0x0954 }, + { first: 0x0962, last: 0x0963 }, + { first: 0x0981, last: 0x0981 }, + { first: 0x09bc, last: 0x09bc }, + { first: 0x09c1, last: 0x09c4 }, + { first: 0x09cd, last: 0x09cd }, + { first: 0x09e2, last: 0x09e3 }, + { first: 0x0a01, last: 0x0a02 }, + { first: 0x0a3c, last: 0x0a3c }, + { first: 0x0a41, last: 0x0a42 }, + { first: 0x0a47, last: 0x0a48 }, + { first: 0x0a4b, last: 0x0a4d }, + { first: 0x0a70, last: 0x0a71 }, + { first: 0x0a81, last: 0x0a82 }, + { first: 0x0abc, last: 0x0abc }, + { first: 0x0ac1, last: 0x0ac5 }, + { first: 0x0ac7, last: 0x0ac8 }, + { first: 0x0acd, last: 0x0acd }, + { first: 0x0ae2, last: 0x0ae3 }, + { first: 0x0b01, last: 0x0b01 }, + { first: 0x0b3c, last: 0x0b3c }, + { first: 0x0b3f, last: 0x0b3f }, + { first: 0x0b41, last: 0x0b43 }, + { first: 0x0b4d, last: 0x0b4d }, + { first: 0x0b56, last: 0x0b56 }, + { first: 0x0b82, last: 0x0b82 }, + { first: 0x0bc0, last: 0x0bc0 }, + { first: 0x0bcd, last: 0x0bcd }, + { first: 0x0c3e, last: 0x0c40 }, + { first: 0x0c46, last: 0x0c48 }, + { first: 0x0c4a, last: 0x0c4d }, + { first: 0x0c55, last: 0x0c56 }, + { first: 0x0cbc, last: 0x0cbc }, + { first: 0x0cbf, last: 0x0cbf }, + { first: 0x0cc6, last: 0x0cc6 }, + { first: 0x0ccc, last: 0x0ccd }, + { first: 0x0ce2, last: 0x0ce3 }, + { first: 0x0d41, last: 0x0d43 }, + { first: 0x0d4d, last: 0x0d4d }, + { first: 0x0dca, last: 0x0dca }, + { first: 0x0dd2, last: 0x0dd4 }, + { first: 0x0dd6, last: 0x0dd6 }, + { first: 0x0e31, last: 0x0e31 }, + { first: 0x0e34, last: 0x0e3a }, + { first: 0x0e47, last: 0x0e4e }, + { first: 0x0eb1, last: 0x0eb1 }, + { first: 0x0eb4, last: 0x0eb9 }, + { first: 0x0ebb, last: 0x0ebc }, + { first: 0x0ec8, last: 0x0ecd }, + { first: 0x0f18, last: 0x0f19 }, + { first: 0x0f35, last: 0x0f35 }, + { first: 0x0f37, last: 0x0f37 }, + { first: 0x0f39, last: 0x0f39 }, + { first: 0x0f71, last: 0x0f7e }, + { first: 0x0f80, last: 0x0f84 }, + { first: 0x0f86, last: 0x0f87 }, + { first: 0x0f90, last: 0x0f97 }, + { first: 0x0f99, last: 0x0fbc }, + { first: 0x0fc6, last: 0x0fc6 }, + { first: 0x102d, last: 0x1030 }, + { first: 0x1032, last: 0x1032 }, + { first: 0x1036, last: 0x1037 }, + { first: 0x1039, last: 0x1039 }, + { first: 0x1058, last: 0x1059 }, + { first: 0x1160, last: 0x11ff }, + { first: 0x135f, last: 0x135f }, + { first: 0x1712, last: 0x1714 }, + { first: 0x1732, last: 0x1734 }, + { first: 0x1752, last: 0x1753 }, + { first: 0x1772, last: 0x1773 }, + { first: 0x17b4, last: 0x17b5 }, + { first: 0x17b7, last: 0x17bd }, + { first: 0x17c6, last: 0x17c6 }, + { first: 0x17c9, last: 0x17d3 }, + { first: 0x17dd, last: 0x17dd }, + { first: 0x180b, last: 0x180d }, + { first: 0x18a9, last: 0x18a9 }, + { first: 0x1920, last: 0x1922 }, + { first: 0x1927, last: 0x1928 }, + { first: 0x1932, last: 0x1932 }, + { first: 0x1939, last: 0x193b }, + { first: 0x1a17, last: 0x1a18 }, + { first: 0x1b00, last: 0x1b03 }, + { first: 0x1b34, last: 0x1b34 }, + { first: 0x1b36, last: 0x1b3a }, + { first: 0x1b3c, last: 0x1b3c }, + { first: 0x1b42, last: 0x1b42 }, + { first: 0x1b6b, last: 0x1b73 }, + { first: 0x1dc0, last: 0x1dca }, + { first: 0x1dfe, last: 0x1dff }, + { first: 0x200b, last: 0x200f }, + { first: 0x202a, last: 0x202e }, + { first: 0x2060, last: 0x2063 }, + { first: 0x206a, last: 0x206f }, + { first: 0x20d0, last: 0x20ef }, + { first: 0x302a, last: 0x302f }, + { first: 0x3099, last: 0x309a }, + { first: 0xa806, last: 0xa806 }, + { first: 0xa80b, last: 0xa80b }, + { first: 0xa825, last: 0xa826 }, + { first: 0xfb1e, last: 0xfb1e }, + { first: 0xfe00, last: 0xfe0f }, + { first: 0xfe20, last: 0xfe23 }, + { first: 0xfeff, last: 0xfeff }, + { first: 0xfff9, last: 0xfffb }, + { first: 0x10a01, last: 0x10a03 }, + { first: 0x10a05, last: 0x10a06 }, + { first: 0x10a0c, last: 0x10a0f }, + { first: 0x10a38, last: 0x10a3a }, + { first: 0x10a3f, last: 0x10a3f }, + { first: 0x1d167, last: 0x1d169 }, + { first: 0x1d173, last: 0x1d182 }, + { first: 0x1d185, last: 0x1d18b }, + { first: 0x1d1aa, last: 0x1d1ad }, + { first: 0x1d242, last: 0x1d244 }, + { first: 0xe0001, last: 0xe0001 }, + { first: 0xe0020, last: 0xe007f }, + { first: 0xe0100, last: 0xe01ef }, +]; + +export default combining; diff --git a/src/lib/utils/simple-wcswidth/src/wcswidth.ts b/src/lib/utils/simple-wcswidth/src/wcswidth.ts new file mode 100644 index 0000000..42106c6 --- /dev/null +++ b/src/lib/utils/simple-wcswidth/src/wcswidth.ts @@ -0,0 +1,19 @@ +import mk_wcwidth from './wcwidth.js'; + +const mk_wcswidth = (pwcs: string) => { + let width = 0; + + // eslint-disable-next-line no-plusplus + for (let i = 0; i < pwcs.length; i++) { + const charCode = pwcs.charCodeAt(i); + const w = mk_wcwidth(charCode); + if (w < 0) { + return -1; + } + width += w; + } + + return width; +}; + +export default mk_wcswidth; diff --git a/src/lib/utils/simple-wcswidth/src/wcwidth.ts b/src/lib/utils/simple-wcswidth/src/wcwidth.ts new file mode 100644 index 0000000..5b7d815 --- /dev/null +++ b/src/lib/utils/simple-wcswidth/src/wcwidth.ts @@ -0,0 +1,72 @@ +import combining from './non-spacing-chars.js'; +import bisearch from './binary-search.js'; + +/* The following two functions define the column width of an ISO 10646 + * character as follows: + * + * - The null character (U+0000) has a column width of 0. + * + * - Other C0/C1 control characters and DEL will lead to a return + * value of -1. + * + * - Non-spacing and enclosing combining characters (general + * category code Mn or Me in the Unicode database) have a + * column width of 0. + * + * - SOFT HYPHEN (U+00AD) has a column width of 1. + * + * - Other format characters (general category code Cf in the Unicode + * database) and ZERO WIDTH SPACE (U+200B) have a column width of 0. + * + * - Hangul Jamo medial vowels and final consonants (U+1160-U+11FF) + * have a column width of 0. + * + * - Spacing characters in the East Asian Wide (W) or East Asian + * Full-width (F) category as defined in Unicode Technical + * Report #11 have a column width of 2. + * + * - All remaining characters (including all printable + * ISO 8859-1 and WGL4 characters, Unicode control characters, + * etc.) have a column width of 1. + * + * This implementation assumes that wchar_t characters are encoded + * in ISO 10646. + */ + +const mk_wcwidth = (ucs: number): number => { + /* test for 8-bit control characters */ + if (ucs === 0) { + return 0; + } + if (ucs < 32 || (ucs >= 0x7f && ucs < 0xa0)) { + return -1; + } + + /* binary search in table of non-spacing characters */ + if (bisearch(ucs, combining, combining.length - 1)) { + return 0; + } + + /* if we arrive here, ucs is not a combining or C0/C1 control character */ + + return ( + 1 + + Number( + ucs >= 0x1100 && + (ucs <= 0x115f /* Hangul Jamo init. consonants */ || + ucs === 0x2329 || + ucs === 0x232a || + (ucs >= 0x2e80 && ucs <= 0xa4cf && ucs !== 0x303f) /* CJK ... Yi */ || + (ucs >= 0xac00 && ucs <= 0xd7a3) /* Hangul Syllables */ || + (ucs >= 0xf900 && ucs <= 0xfaff) /* CJK Compatibility Ideographs */ || + (ucs >= 0xfe10 && ucs <= 0xfe19) /* Vertical forms */ || + (ucs >= 0xfe30 && ucs <= 0xfe6f) /* CJK Compatibility Forms */ || + (ucs >= 0xff00 && ucs <= 0xff60) /* Fullwidth Forms */ || + (ucs >= 0xffe0 && ucs <= 0xffe6) || + (ucs >= 0x20000 && ucs <= 0x2fffd) || + (ucs >= 0x30000 && ucs <= 0x3fffd)) + ) + ); +}; + +export default mk_wcwidth; diff --git a/src/lib/utils/types.ts b/src/lib/utils/types.ts new file mode 100644 index 0000000..6a65403 --- /dev/null +++ b/src/lib/utils/types.ts @@ -0,0 +1,13 @@ +export const isRecord = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +export const isPrimitive = ( + value: unknown, +): value is string | number | boolean => { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +}; diff --git a/src/lib/utils/url.ts b/src/lib/utils/url.ts new file mode 100644 index 0000000..7c35c4d --- /dev/null +++ b/src/lib/utils/url.ts @@ -0,0 +1,28 @@ +/** + * Resolve a path the API returned against the client's API URL. + * + * Self-hosted deployments return attachment `presigned_url`s as paths such as + * `/api/v1/public/download?jwt=...`. Mirrors Python's `_construct_url`, including + * the de-duplication of an `/api` or `/api/v1` suffix already present on apiUrl. + */ +export function constructUrl(apiUrl: string, pathname: string): string { + if (pathname.startsWith("http")) return pathname; + const match = apiUrl.match(/^(https?:\/\/)(.*)$/); + if (!match) { + throw new Error( + `api_url must start with 'http://' or 'https://'. Received ${apiUrl}`, + ); + } + const [, scheme, host] = match; + let apiParts = host.replace(/\/+$/, "").split("/"); + const pathParts = pathname.replace(/^\/+/, "").split("/").filter(Boolean); + if (pathParts.length === 0) return apiUrl; + if (pathParts[0] === "api") { + if (apiParts[apiParts.length - 1] === "api") { + apiParts = apiParts.slice(0, -1); + } else if (apiParts.slice(-2).join("/") === "api/v1") { + apiParts = apiParts.slice(0, -2); + } + } + return scheme + [...apiParts, ...pathParts].filter(Boolean).join("/"); +} diff --git a/src/lib/utils/usage.ts b/src/lib/utils/usage.ts new file mode 100644 index 0000000..106aea3 --- /dev/null +++ b/src/lib/utils/usage.ts @@ -0,0 +1,32 @@ +export const convertAnthropicUsageToInputTokenDetails = ( + usage: Record, +): Record => { + const result: { + ephemeral_5m_input_tokens?: number; + ephemeral_1hr_input_tokens?: number; + cache_read?: number; + } = {}; + + if ( + usage.cache_creation != null && + typeof usage.cache_creation === "object" + ) { + const cacheCreation = usage.cache_creation as Record; + if (typeof cacheCreation.ephemeral_5m_input_tokens === "number") { + result.ephemeral_5m_input_tokens = + cacheCreation.ephemeral_5m_input_tokens; + } + if (typeof cacheCreation.ephemeral_1h_input_tokens === "number") { + result.ephemeral_1hr_input_tokens = + cacheCreation.ephemeral_1h_input_tokens; + } + // If cache_creation not returned (no beta header passed), + // fallback to assuming 5m cache tokens + } else if (typeof usage.cache_creation_input_tokens === "number") { + result.ephemeral_5m_input_tokens = usage.cache_creation_input_tokens; + } + if (typeof usage.cache_read_input_tokens === "number") { + result.cache_read = usage.cache_read_input_tokens; + } + return result; +}; diff --git a/src/lib/utils/uuid/LICENSE.md b/src/lib/utils/uuid/LICENSE.md new file mode 100644 index 0000000..3934168 --- /dev/null +++ b/src/lib/utils/uuid/LICENSE.md @@ -0,0 +1,9 @@ +The MIT License (MIT) + +Copyright (c) 2010-2020 Robert Kieffer and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/src/lib/utils/uuid/src/index.ts b/src/lib/utils/uuid/src/index.ts new file mode 100644 index 0000000..5044f14 --- /dev/null +++ b/src/lib/utils/uuid/src/index.ts @@ -0,0 +1,10 @@ +export { default as MAX } from "./max.js"; +export { default as NIL } from "./nil.js"; +export { default as parse } from "./parse.js"; +export { default as stringify } from "./stringify.js"; +export type * from "./types.js"; +export { default as v4 } from "./v4.js"; +export { default as v5 } from "./v5.js"; +export { default as v7 } from "./v7.js"; +export { default as validate } from "./validate.js"; +export { default as version } from "./version.js"; diff --git a/src/lib/utils/uuid/src/max.ts b/src/lib/utils/uuid/src/max.ts new file mode 100644 index 0000000..1779487 --- /dev/null +++ b/src/lib/utils/uuid/src/max.ts @@ -0,0 +1 @@ +export default "ffffffff-ffff-ffff-ffff-ffffffffffff"; diff --git a/src/lib/utils/uuid/src/nil.ts b/src/lib/utils/uuid/src/nil.ts new file mode 100644 index 0000000..e106f8a --- /dev/null +++ b/src/lib/utils/uuid/src/nil.ts @@ -0,0 +1 @@ +export default "00000000-0000-0000-0000-000000000000"; diff --git a/src/lib/utils/uuid/src/parse.ts b/src/lib/utils/uuid/src/parse.ts new file mode 100644 index 0000000..c0cad2b --- /dev/null +++ b/src/lib/utils/uuid/src/parse.ts @@ -0,0 +1,39 @@ +import type { NonSharedArrayBuffer } from "./types.js"; +import validate from "./validate.js"; + +function parse(uuid: string): NonSharedArrayBuffer { + if (!validate(uuid)) { + throw TypeError("Invalid UUID"); + } + + let v: number; + return Uint8Array.of( + (v = parseInt(uuid.slice(0, 8), 16)) >>> 24, + (v >>> 16) & 0xff, + (v >>> 8) & 0xff, + v & 0xff, + + // Parse ........-####-....-....-............ + (v = parseInt(uuid.slice(9, 13), 16)) >>> 8, + v & 0xff, + + // Parse ........-....-####-....-............ + (v = parseInt(uuid.slice(14, 18), 16)) >>> 8, + v & 0xff, + + // Parse ........-....-....-####-............ + (v = parseInt(uuid.slice(19, 23), 16)) >>> 8, + v & 0xff, + + // Parse ........-....-....-....-############ + // (Use "/" to avoid 32-bit truncation when bit-shifting high-order bytes) + ((v = parseInt(uuid.slice(24, 36), 16)) / 0x10000000000) & 0xff, + (v / 0x100000000) & 0xff, + (v >>> 24) & 0xff, + (v >>> 16) & 0xff, + (v >>> 8) & 0xff, + v & 0xff, + ); +} + +export default parse; diff --git a/src/lib/utils/uuid/src/regex.ts b/src/lib/utils/uuid/src/regex.ts new file mode 100644 index 0000000..3e38591 --- /dev/null +++ b/src/lib/utils/uuid/src/regex.ts @@ -0,0 +1 @@ +export default /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i; diff --git a/src/lib/utils/uuid/src/rng.ts b/src/lib/utils/uuid/src/rng.ts new file mode 100644 index 0000000..6553206 --- /dev/null +++ b/src/lib/utils/uuid/src/rng.ts @@ -0,0 +1,7 @@ +// RNG values for use in UUID generation. This *must* use a high-quality source +// of entropy, such as `crypto.getRandomValues()`. And we reuse an array for +// performance. +const rnds8 = new Uint8Array(16); +export default function rng() { + return crypto.getRandomValues(rnds8); +} diff --git a/src/lib/utils/uuid/src/sha1.ts b/src/lib/utils/uuid/src/sha1.ts new file mode 100644 index 0000000..37dc66d --- /dev/null +++ b/src/lib/utils/uuid/src/sha1.ts @@ -0,0 +1,110 @@ +// Adapted from Chris Veness' SHA1 code at +// http://www.movable-type.co.uk/scripts/sha1.html +function f(s: 0 | 1 | 2 | 3, x: number, y: number, z: number) { + switch (s) { + case 0: + return (x & y) ^ (~x & z); + case 1: + return x ^ y ^ z; + case 2: + return (x & y) ^ (x & z) ^ (y & z); + case 3: + return x ^ y ^ z; + } +} + +function ROTL(x: number, n: number) { + return (x << n) | (x >>> (32 - n)); +} + +function sha1(bytes: Uint8Array): Uint8Array { + const K = [0x5a827999, 0x6ed9eba1, 0x8f1bbcdc, 0xca62c1d6]; + const H = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476, 0xc3d2e1f0]; + + const newBytes = new Uint8Array(bytes.length + 1); + newBytes.set(bytes); + newBytes[bytes.length] = 0x80; + bytes = newBytes; + + const l = bytes.length / 4 + 2; + const N = Math.ceil(l / 16); + const M: Uint32Array[] = new Array(N); + + for (let i = 0; i < N; ++i) { + const arr = new Uint32Array(16); + + for (let j = 0; j < 16; ++j) { + arr[j] = + (bytes[i * 64 + j * 4] << 24) | + (bytes[i * 64 + j * 4 + 1] << 16) | + (bytes[i * 64 + j * 4 + 2] << 8) | + bytes[i * 64 + j * 4 + 3]; + } + + M[i] = arr; + } + + M[N - 1][14] = ((bytes.length - 1) * 8) / 2 ** 32; + M[N - 1][14] = Math.floor(M[N - 1][14]); + M[N - 1][15] = ((bytes.length - 1) * 8) & 0xffffffff; + + for (let i = 0; i < N; ++i) { + const W = new Uint32Array(80); + + for (let t = 0; t < 16; ++t) { + W[t] = M[i][t]; + } + + for (let t = 16; t < 80; ++t) { + W[t] = ROTL(W[t - 3] ^ W[t - 8] ^ W[t - 14] ^ W[t - 16], 1); + } + + let a = H[0]; + let b = H[1]; + let c = H[2]; + let d = H[3]; + let e = H[4]; + + for (let t = 0; t < 80; ++t) { + const s = Math.floor(t / 20) as 0 | 1 | 2 | 3; + const T = (ROTL(a, 5) + f(s, b, c, d) + e + K[s] + W[t]) >>> 0; + e = d; + d = c; + c = ROTL(b, 30) >>> 0; + b = a; + a = T; + } + + H[0] = (H[0] + a) >>> 0; + H[1] = (H[1] + b) >>> 0; + H[2] = (H[2] + c) >>> 0; + H[3] = (H[3] + d) >>> 0; + H[4] = (H[4] + e) >>> 0; + } + + // Note: Uint8Array.of() does `& 0xff` for each value + return Uint8Array.of( + H[0] >> 24, + H[0] >> 16, + H[0] >> 8, + H[0], + H[1] >> 24, + H[1] >> 16, + H[1] >> 8, + H[1], + H[2] >> 24, + H[2] >> 16, + H[2] >> 8, + H[2], + H[3] >> 24, + H[3] >> 16, + H[3] >> 8, + H[3], + H[4] >> 24, + H[4] >> 16, + H[4] >> 8, + H[4], + ); +} + +export default sha1; diff --git a/src/lib/utils/uuid/src/stringify.ts b/src/lib/utils/uuid/src/stringify.ts new file mode 100644 index 0000000..c4cac2d --- /dev/null +++ b/src/lib/utils/uuid/src/stringify.ts @@ -0,0 +1,58 @@ +import validate from "./validate.js"; + +/** + * Convert array of 16 byte values to UUID string format of the form: + * XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX + */ +const byteToHex: string[] = []; + +for (let i = 0; i < 256; ++i) { + byteToHex.push((i + 0x100).toString(16).slice(1)); +} + +export function unsafeStringify(arr: Uint8Array, offset = 0): string { + // Note: Be careful editing this code! It's been tuned for performance + // and works in ways you may not expect. See https://github.com/uuidjs/uuid/pull/434 + // + // Note to future-self: No, you can't remove the `toLowerCase()` call. + // REF: https://github.com/uuidjs/uuid/pull/677#issuecomment-1757351351 + return ( + byteToHex[arr[offset + 0]] + + byteToHex[arr[offset + 1]] + + byteToHex[arr[offset + 2]] + + byteToHex[arr[offset + 3]] + + "-" + + byteToHex[arr[offset + 4]] + + byteToHex[arr[offset + 5]] + + "-" + + byteToHex[arr[offset + 6]] + + byteToHex[arr[offset + 7]] + + "-" + + byteToHex[arr[offset + 8]] + + byteToHex[arr[offset + 9]] + + "-" + + byteToHex[arr[offset + 10]] + + byteToHex[arr[offset + 11]] + + byteToHex[arr[offset + 12]] + + byteToHex[arr[offset + 13]] + + byteToHex[arr[offset + 14]] + + byteToHex[arr[offset + 15]] + ).toLowerCase(); +} + +function stringify(arr: Uint8Array, offset = 0) { + const uuid = unsafeStringify(arr, offset); + + // Consistency check for valid UUID. If this throws, it's likely due to one + // of the following: + // - One or more input array values don't map to a hex octet (leading to + // "undefined" in the uuid) + // - Invalid input values for the RFC `version` or `variant` fields + if (!validate(uuid)) { + throw TypeError("Stringified UUID is invalid"); + } + + return uuid; +} + +export default stringify; diff --git a/src/lib/utils/uuid/src/types.ts b/src/lib/utils/uuid/src/types.ts new file mode 100644 index 0000000..b0eb125 --- /dev/null +++ b/src/lib/utils/uuid/src/types.ts @@ -0,0 +1,28 @@ +export type UUIDTypes = string | TBuf; + +export type Version1Options = { + node?: Uint8Array; + clockseq?: number; + random?: Uint8Array; + rng?: () => Uint8Array; + msecs?: number; + nsecs?: number; + _v6?: boolean; // Internal use only! +}; + +export type Version4Options = { + random?: Uint8Array; + rng?: () => Uint8Array; +}; + +export type Version6Options = Version1Options; + +export type Version7Options = { + random?: Uint8Array; + msecs?: number; + seq?: number; + rng?: () => Uint8Array; +}; + +// Workaround for different versions of TypeScript definitions +export type NonSharedArrayBuffer = ReturnType; diff --git a/src/lib/utils/uuid/src/v35.ts b/src/lib/utils/uuid/src/v35.ts new file mode 100644 index 0000000..bf0ef1b --- /dev/null +++ b/src/lib/utils/uuid/src/v35.ts @@ -0,0 +1,74 @@ +import parse from "./parse.js"; +import { unsafeStringify } from "./stringify.js"; +import type { UUIDTypes } from "./types.js"; + +export function stringToBytes(str: string) { + // TODO: Use TextEncoder (see https://stackoverflow.com/a/48762658/109538) + str = unescape(encodeURIComponent(str)); + + const bytes = new Uint8Array(str.length); + + for (let i = 0; i < str.length; ++i) { + bytes[i] = str.charCodeAt(i); + } + + return bytes; +} + +export const DNS = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"; +export const URL = "6ba7b811-9dad-11d1-80b4-00c04fd430c8"; + +type HashFunction = (bytes: Uint8Array) => Uint8Array; + +export default function v35( + version: 0x30 | 0x50, + hash: HashFunction, + value: string | Uint8Array, + namespace: UUIDTypes, + buf?: TBuf, + offset?: number, +): UUIDTypes { + const valueBytes: Uint8Array = + typeof value === "string" ? stringToBytes(value) : value; + const namespaceBytes: Uint8Array = + typeof namespace === "string" ? parse(namespace) : namespace; + + if (typeof namespace === "string") { + namespace = parse(namespace); + } + + if (namespace?.length !== 16) { + throw TypeError( + "Namespace must be array-like (16 iterable integer values, 0-255)", + ); + } + + // Compute hash of namespace and value, Per 4.3 + // Future: Use spread syntax when supported on all platforms, e.g. `bytes = + // hashfunc([...namespace, ... value])` + let bytes = new Uint8Array(16 + valueBytes.length); + bytes.set(namespaceBytes); + bytes.set(valueBytes, namespaceBytes.length); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bytes = hash(bytes) as any; + + bytes[6] = (bytes[6] & 0x0f) | version; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + + if (buf) { + offset ??= 0; + if (offset < 0 || offset + 16 > buf.length) { + throw new RangeError( + `UUID byte range ${offset}:${offset + 15} is out of buffer bounds`, + ); + } + + for (let i = 0; i < 16; ++i) { + buf[offset + i] = bytes[i]; + } + + return buf; + } + + return unsafeStringify(bytes); +} diff --git a/src/lib/utils/uuid/src/v4.ts b/src/lib/utils/uuid/src/v4.ts new file mode 100644 index 0000000..e3c73bb --- /dev/null +++ b/src/lib/utils/uuid/src/v4.ts @@ -0,0 +1,65 @@ +import rng from "./rng.js"; +import { unsafeStringify } from "./stringify.js"; +import type { UUIDTypes, Version4Options } from "./types.js"; + +function v4( + options?: Version4Options, + buf?: undefined, + offset?: number, +): string; +function v4( + options: Version4Options | undefined, + buf: TBuf, + offset?: number, +): TBuf; +function v4( + options?: Version4Options, + buf?: TBuf, + offset?: number, +): UUIDTypes { + if (!buf && !options && crypto.randomUUID) { + return crypto.randomUUID(); + } + + // Putting tail-code that could just go inline here in a separate function to + // enable compiler optimizations that dramatically improve performance. + // + // REF: https://github.com/uuidjs/uuid/issues/892 + return _v4(options, buf, offset); +} + +function _v4( + options?: Version4Options, + buf?: TBuf, + offset?: number, +): UUIDTypes { + options = options || {}; + + const rnds = options.random ?? options.rng?.() ?? rng(); + if (rnds.length < 16) { + throw new Error("Random bytes length must be >= 16"); + } + + // Per 4.4, set bits for version and `clock_seq_hi_and_reserved` + rnds[6] = (rnds[6] & 0x0f) | 0x40; + rnds[8] = (rnds[8] & 0x3f) | 0x80; + + // Copy bytes to buffer, if provided + if (buf) { + offset = offset || 0; + if (offset < 0 || offset + 16 > buf.length) { + throw new RangeError( + `UUID byte range ${offset}:${offset + 15} is out of buffer bounds`, + ); + } + + for (let i = 0; i < 16; ++i) { + buf[offset + i] = rnds[i]; + } + + return buf; + } + + return unsafeStringify(rnds); +} +export default v4; diff --git a/src/lib/utils/uuid/src/v5.ts b/src/lib/utils/uuid/src/v5.ts new file mode 100644 index 0000000..14cf63d --- /dev/null +++ b/src/lib/utils/uuid/src/v5.ts @@ -0,0 +1,31 @@ +import sha1 from "./sha1.js"; +import type { UUIDTypes } from "./types.js"; +import v35, { DNS, URL } from "./v35.js"; + +export { DNS, URL } from "./v35.js"; + +function v5( + value: string | Uint8Array, + namespace: UUIDTypes, + buf?: undefined, + offset?: number, +): string; +function v5( + value: string | Uint8Array, + namespace: UUIDTypes, + buf: TBuf, + offset?: number, +): TBuf; +function v5( + value: string | Uint8Array, + namespace: UUIDTypes, + buf?: TBuf, + offset?: number, +): UUIDTypes { + return v35(0x50, sha1, value, namespace, buf, offset); +} + +v5.DNS = DNS; +v5.URL = URL; + +export default v5; diff --git a/src/lib/utils/uuid/src/v7.ts b/src/lib/utils/uuid/src/v7.ts new file mode 100644 index 0000000..7fec95e --- /dev/null +++ b/src/lib/utils/uuid/src/v7.ts @@ -0,0 +1,135 @@ +import rng from "./rng.js"; +import { unsafeStringify } from "./stringify.js"; +import type { UUIDTypes, Version7Options } from "./types.js"; + +type V7State = { + msecs?: number; // time, milliseconds + seq?: number; // sequence number (32-bits) +}; + +const _state: V7State = {}; + +function v7( + options?: Version7Options, + buf?: undefined, + offset?: number, +): string; +function v7( + options: Version7Options | undefined, + buf: TBuf, + offset?: number, +): TBuf; +function v7( + options?: Version7Options, + buf?: TBuf, + offset?: number, +): UUIDTypes { + let bytes: Uint8Array; + + if (options) { + // With options: Make UUID independent of internal state + bytes = v7Bytes( + options.random ?? options.rng?.() ?? rng(), + options.msecs, + options.seq, + buf, + offset, + ); + } else { + // No options: Use internal state + const now = Date.now(); + const rnds = rng(); + + updateV7State(_state, now, rnds); + + bytes = v7Bytes(rnds, _state.msecs, _state.seq, buf, offset); + } + + return buf ?? unsafeStringify(bytes); +} + +// (Private!) Do not use. This method is only exported for testing purposes +// and may change without notice. +export function updateV7State(state: V7State, now: number, rnds: Uint8Array) { + state.msecs ??= -Infinity; + state.seq ??= 0; + + if (now > state.msecs) { + // Time has moved on! Pick a new random sequence number + state.seq = (rnds[6] << 23) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9]; + state.msecs = now; + } else { + // Bump sequence counter w/ 32-bit rollover + state.seq = (state.seq + 1) | 0; + + // In case of rollover, bump timestamp to preserve monotonicity. This is + // allowed by the RFC and should self-correct as the system clock catches + // up. See https://www.rfc-editor.org/rfc/rfc9562.html#section-6.2-9.4 + if (state.seq === 0) { + state.msecs++; + } + } + + return state; +} + +function v7Bytes( + rnds: Uint8Array, + msecs?: number, + seq?: number, + buf?: Uint8Array, + offset = 0, +) { + if (rnds.length < 16) { + throw new Error("Random bytes length must be >= 16"); + } + + if (!buf) { + buf = new Uint8Array(16); + offset = 0; + } else { + if (offset < 0 || offset + 16 > buf.length) { + throw new RangeError( + `UUID byte range ${offset}:${offset + 15} is out of buffer bounds`, + ); + } + } + + // Defaults + msecs ??= Date.now(); + seq ??= ((rnds[6] * 0x7f) << 24) | (rnds[7] << 16) | (rnds[8] << 8) | rnds[9]; + + // byte 0-5: timestamp (48 bits) + buf[offset++] = (msecs / 0x10000000000) & 0xff; + buf[offset++] = (msecs / 0x100000000) & 0xff; + buf[offset++] = (msecs / 0x1000000) & 0xff; + buf[offset++] = (msecs / 0x10000) & 0xff; + buf[offset++] = (msecs / 0x100) & 0xff; + buf[offset++] = msecs & 0xff; + + // byte 6: `version` (4 bits) | sequence bits 28-31 (4 bits) + buf[offset++] = 0x70 | ((seq >>> 28) & 0x0f); + + // byte 7: sequence bits 20-27 (8 bits) + buf[offset++] = (seq >>> 20) & 0xff; + + // byte 8: `variant` (2 bits) | sequence bits 14-19 (6 bits) + buf[offset++] = 0x80 | ((seq >>> 14) & 0x3f); + + // byte 9: sequence bits 6-13 (8 bits) + buf[offset++] = (seq >>> 6) & 0xff; + + // byte 10: sequence bits 0-5 (6 bits) | random (2 bits) + buf[offset++] = ((seq << 2) & 0xff) | (rnds[10] & 0x03); + + // bytes 11-15: random (40 bits) + buf[offset++] = rnds[11]; + buf[offset++] = rnds[12]; + buf[offset++] = rnds[13]; + buf[offset++] = rnds[14]; + buf[offset++] = rnds[15]; + + return buf; +} + +export default v7; diff --git a/src/lib/utils/uuid/src/validate.ts b/src/lib/utils/uuid/src/validate.ts new file mode 100644 index 0000000..e526248 --- /dev/null +++ b/src/lib/utils/uuid/src/validate.ts @@ -0,0 +1,7 @@ +import REGEX from "./regex.js"; + +function validate(uuid: unknown) { + return typeof uuid === "string" && REGEX.test(uuid); +} + +export default validate; diff --git a/src/lib/utils/uuid/src/version.ts b/src/lib/utils/uuid/src/version.ts new file mode 100644 index 0000000..66749fd --- /dev/null +++ b/src/lib/utils/uuid/src/version.ts @@ -0,0 +1,11 @@ +import validate from "./validate.js"; + +function version(uuid: string) { + if (!validate(uuid)) { + throw TypeError("Invalid UUID"); + } + + return parseInt(uuid.slice(14, 15), 16); +} + +export default version; diff --git a/src/lib/utils/v2_migration.ts b/src/lib/utils/v2_migration.ts new file mode 100644 index 0000000..2085c61 --- /dev/null +++ b/src/lib/utils/v2_migration.ts @@ -0,0 +1,85 @@ +import type { + Run as V2Run, + RunSelectField, +} from "../../resources/runs/runs.js"; +import type { Client } from "../client.js"; +import type { Run, TracerSession } from "../schemas.js"; + +export const QueryBackend = { + CLICKHOUSE_ONLY: "clickhouse_only", + SMITHDB_ONLY: "smithdb_only", + DUAL: "dual", +} as const; + +export type QueryBackend = (typeof QueryBackend)[keyof typeof QueryBackend]; + +/** Which backend(s) `/info`'s `instance_flags` indicate for run/trace queries. */ +export function getQueryBackend( + instanceFlags?: Record | null, +): QueryBackend { + const flags = instanceFlags ?? {}; + const chEnabled = Boolean(flags.ch_query_enabled ?? true); + const sdbEnabled = Boolean(flags.sdb_query_enabled ?? false); + if (!chEnabled && sdbEnabled) return QueryBackend.SMITHDB_ONLY; + if (chEnabled && sdbEnabled) return QueryBackend.DUAL; + return QueryBackend.CLICKHOUSE_ONLY; +} + +// Omitting selects from `/v2/runs/query` returns only the run ID. +const V2_RUN_SELECTS: RunSelectField[] = [ + "ID", + "NAME", + "RUN_TYPE", + "STATUS", + "START_TIME", + "END_TIME", + "INPUTS", + "OUTPUTS", + "PARENT_RUN_IDS", + "PROJECT_ID", + "TRACE_ID", + "DOTTED_ORDER", + "REFERENCE_EXAMPLE_ID", + "ERROR", +]; + +export function v2RunToRun(run: V2Run): Run { + if (run.id == null || run.name == null || run.run_type == null) { + throw new Error("V2 run response is missing required fields."); + } + return { + id: run.id, + name: run.name, + run_type: run.run_type.toLowerCase(), + start_time: run.start_time, + end_time: run.end_time, + trace_id: run.trace_id, + session_id: run.project_id, + parent_run_id: run.parent_run_ids?.at(-1), + dotted_order: run.dotted_order, + reference_example_id: run.reference_example_id, + inputs: run.inputs ?? {}, + outputs: run.outputs, + error: run.error ?? undefined, + status: run.status?.toLowerCase(), + }; +} + +export async function loadTracesV2( + client: Client, + project: TracerSession, + options: { isRoot?: boolean }, +): Promise { + const runs: Run[] = []; + const pager = client.runs.queryV2({ + project_ids: [project.id], + min_start_time: new Date(project.start_time).toISOString(), + max_start_time: new Date(project.end_time ?? Date.now()).toISOString(), + is_root: options.isRoot, + selects: V2_RUN_SELECTS, + }); + for await (const run of pager) { + runs.push(v2RunToRun(run)); + } + return runs; +} diff --git a/src/lib/utils/vercel.ts b/src/lib/utils/vercel.ts new file mode 100644 index 0000000..07218ba --- /dev/null +++ b/src/lib/utils/vercel.ts @@ -0,0 +1,402 @@ +import type { + LanguageModelV2Usage, + LanguageModelV3Usage, +} from "@ai-sdk/provider"; +import type { LanguageModelUsage } from "ai"; +import { KVMap } from "../schemas.js"; +import { convertAnthropicUsageToInputTokenDetails } from "./usage.js"; +import { isRecord } from "./types.js"; + +function extractTraceableServiceTier( + providerMetadata: Record, +): "priority" | "flex" | undefined { + if ( + providerMetadata?.openai != null && + typeof providerMetadata.openai === "object" + ) { + const openai = providerMetadata.openai as Record; + if ( + openai.serviceTier != null && + typeof openai.serviceTier === "string" && + ["priority", "flex"].includes(openai.serviceTier) + ) { + return openai.serviceTier as "priority" | "flex"; + } + } + return undefined; +} + +function isLanguageModelV3Usage( + usage: Partial | Partial, +): usage is Partial { + return usage.inputTokens != null && typeof usage.inputTokens === "object"; +} + +function isTelemetryLanguageModelUsage( + usage: unknown, +): usage is Partial { + if (!isRecord(usage)) return false; + + return ( + (usage.inputTokens == null || typeof usage.inputTokens === "number") && + isRecord(usage.inputTokenDetails) && + (usage.outputTokens == null || typeof usage.outputTokens === "number") && + isRecord(usage.outputTokenDetails) + ); +} + +function extractAISDK6OutputTokenDetails( + usage: Partial, + providerMetadata?: Record, +) { + const openAIServiceTier = extractTraceableServiceTier(providerMetadata ?? {}); + const outputTokenDetailsKeyPrefix = openAIServiceTier + ? `${openAIServiceTier}_` + : ""; + const outputTokens = usage.outputTokens; + const outputTokenDetails: Record = {}; + + // Extract reasoning tokens from AI SDK 6 + if ( + typeof outputTokens?.reasoning === "number" && + outputTokens?.reasoning > 0 + ) { + outputTokenDetails[`${outputTokenDetailsKeyPrefix}reasoning`] = + outputTokens.reasoning; + } + + // Apply service tier logic for AI SDK 6 + if (openAIServiceTier && typeof outputTokens?.total === "number") { + // Avoid counting reasoning tokens towards the output token count + // since service tier tokens are already priced differently + outputTokenDetails[openAIServiceTier] = + outputTokens.total - + (outputTokenDetails[`${outputTokenDetailsKeyPrefix}reasoning`] ?? 0); + } + return outputTokenDetails; +} + +// TODO: verify service tier token counting +function extractTelemetryOutputTokenDetails( + usage: Partial, + providerMetadata: Record | undefined, +) { + const result: { + reasoning?: number; + + flex?: number; + flex_reasoning?: number; + + priority?: number; + priority_reasoning?: number; + } = {}; + + const serviceTier = extractTraceableServiceTier(providerMetadata ?? {}); + const prefix = serviceTier ? (`${serviceTier}_` as const) : ("" as const); + const reasoning = usage.outputTokenDetails?.reasoningTokens ?? undefined; + + if (typeof reasoning === "number" && reasoning > 0) { + const key = `${prefix}reasoning` as const; + result[key] = reasoning; + } + + // Apply service tier logic + if (serviceTier != null) { + // Avoid counting reasoning tokens towards the output token count + // since service tier tokens are already priced differently + result[serviceTier] = (usage.outputTokens ?? 0) - (reasoning ?? 0); + } + + return result; +} + +export function extractOutputTokenDetails( + usage?: Partial | Partial, + providerMetadata?: Record, +) { + if (usage == null) { + return {}; + } + + // AI SDK 7: Use Telemetry Language Model Usage + if (isTelemetryLanguageModelUsage(usage)) { + return extractTelemetryOutputTokenDetails(usage, providerMetadata); + } + + // AI SDK 6: Check for built-in outputTokens breakdown first + if (isLanguageModelV3Usage(usage)) { + // Return AI SDK 6 results (even if empty, to prevent falling through to SDK 5 logic) + return extractAISDK6OutputTokenDetails(usage, providerMetadata); + } + + // AI SDK <= 5 + const openAIServiceTier = extractTraceableServiceTier(providerMetadata ?? {}); + const prefix = openAIServiceTier ? `${openAIServiceTier}_` : ""; + const result: Record = {}; + if (typeof usage?.reasoningTokens === "number") { + result[`${prefix}reasoning`] = usage.reasoningTokens; + } + if (openAIServiceTier && typeof usage?.outputTokens === "number") { + // Avoid counting reasoning tokens towards the output token count + // since service tier tokens are already priced differently + result[openAIServiceTier] = + usage.outputTokens - (result[`${prefix}reasoning`] ?? 0); + } + return result; +} + +function extractAISDK6InputTokenDetails( + usage: Partial, + providerMetadata?: Record, +) { + let inputTokenDetails: Record = {}; + const inputTokens = usage.inputTokens; + + // Extract standard AI SDK 6 input token breakdowns + // Map AI SDK 6 fields to LangSmith token detail fields: + // - cacheRead -> cache_read + // - cacheWrite -> cache_creation + if ( + providerMetadata?.anthropic != null && + typeof providerMetadata?.anthropic === "object" + ) { + const anthropic = providerMetadata.anthropic as Record; + if (anthropic.usage != null && typeof anthropic.usage === "object") { + // Raw usage from Anthropic returned in AI SDK 5 + const usage = anthropic.usage as Record; + inputTokenDetails = convertAnthropicUsageToInputTokenDetails(usage); + } + } else { + if ( + typeof inputTokens?.cacheRead === "number" && + inputTokens.cacheRead > 0 + ) { + inputTokenDetails.cache_read = inputTokens.cacheRead; + } + if ( + typeof inputTokens?.cacheWrite === "number" && + inputTokens?.cacheWrite > 0 + ) { + inputTokenDetails.cache_creation = inputTokens?.cacheWrite; + } + } + + // Handle OpenAI service tier for AI SDK 6 + const openAIServiceTier = extractTraceableServiceTier(providerMetadata ?? {}); + if (openAIServiceTier) { + const serviceTierPrefix = `${openAIServiceTier}_`; + + // Add cache_read with service tier prefix if we have cached tokens + if ( + typeof inputTokens?.cacheRead === "number" && + inputTokens?.cacheRead > 0 + ) { + inputTokenDetails[`${serviceTierPrefix}cache_read`] = + inputTokens.cacheRead; + // Remove the non-prefixed version since we're using service tier + delete inputTokenDetails.cache_read; + } + + // Calculate service tier tokens (total minus cached) + if (typeof inputTokens?.total === "number") { + inputTokenDetails[openAIServiceTier] = + inputTokens.total - + (inputTokenDetails[`${serviceTierPrefix}cache_read`] ?? 0); + } + } + return inputTokenDetails; +} + +function extractTelemetryInputTokenDetails( + usage: Partial, + providerMetadata: Record | undefined, +) { + const result: { + cache_read?: number; + cache_creation?: number; + + flex?: number; + flex_cache_read?: number; + flex_cache_creation?: number; + + priority?: number; + priority_cache_read?: number; + priority_cache_creation?: number; + } = {}; + + const cacheRead = usage.inputTokenDetails?.cacheReadTokens ?? undefined; + const cacheWrite = usage.inputTokenDetails?.cacheWriteTokens ?? undefined; + + if (typeof cacheRead === "number" && cacheRead > 0) { + result.cache_read = cacheRead; + } + + if (typeof cacheWrite === "number" && cacheWrite > 0) { + result.cache_creation = cacheWrite; + } + + const openAIServiceTier = extractTraceableServiceTier(providerMetadata ?? {}); + const prefix = openAIServiceTier + ? (`${openAIServiceTier}_` as const) + : ("" as const); + + if (openAIServiceTier && typeof usage.inputTokens === "number") { + if (result.cache_read != null) { + result[`${prefix}cache_read` as const] = result.cache_read; + delete result.cache_read; + } + + if (typeof usage?.inputTokens === "number") { + result[openAIServiceTier] = + usage.inputTokens - (result[`${prefix}cache_read`] ?? 0); + } + } + return result; +} + +export function extractInputTokenDetails( + usage?: Partial | Partial, + providerMetadata?: Record, +) { + if (usage == null) { + return {}; + } + + // AI SDK 7: Use Telemetry Language Model Usage + if (isTelemetryLanguageModelUsage(usage)) { + return extractTelemetryInputTokenDetails(usage, providerMetadata); + } + + // AI SDK 6: Check for built-in inputTokens breakdown first + if (isLanguageModelV3Usage(usage)) { + // Return AI SDK 6 results (even if empty, to prevent falling through to SDK 5 logic) + return extractAISDK6InputTokenDetails( + usage as Partial, + providerMetadata, + ); + } + let inputTokenDetails: Record = {}; + if ( + providerMetadata?.anthropic != null && + typeof providerMetadata?.anthropic === "object" + ) { + const anthropic = providerMetadata.anthropic as Record; + if (anthropic.usage != null && typeof anthropic.usage === "object") { + // Raw usage from Anthropic returned in AI SDK 5 + const usage = anthropic.usage as Record; + inputTokenDetails = convertAnthropicUsageToInputTokenDetails(usage); + } else { + // AI SDK 4 fields + if ( + anthropic.cacheReadInputTokens != null && + typeof anthropic.cacheReadInputTokens === "number" + ) { + inputTokenDetails.cache_read = anthropic.cacheReadInputTokens; + } + if ( + anthropic.cacheCreationInputTokens != null && + typeof anthropic.cacheCreationInputTokens === "number" + ) { + inputTokenDetails.ephemeral_5m_input_tokens = + anthropic.cacheCreationInputTokens; + } + } + return inputTokenDetails; + } else if ( + providerMetadata?.openai != null && + typeof providerMetadata?.openai === "object" + ) { + const openAIServiceTier = extractTraceableServiceTier( + providerMetadata ?? {}, + ); + const outputTokenDetailsKeyPrefix = openAIServiceTier + ? `${openAIServiceTier}_` + : ""; + if (typeof usage?.cachedInputTokens === "number") { + inputTokenDetails[`${outputTokenDetailsKeyPrefix}cache_read`] = + usage.cachedInputTokens; + } else if ( + "cachedPromptTokens" in providerMetadata.openai && + providerMetadata.openai.cachedPromptTokens != null && + typeof providerMetadata.openai.cachedPromptTokens === "number" + ) { + inputTokenDetails[`${outputTokenDetailsKeyPrefix}cache_read`] = + providerMetadata.openai.cachedPromptTokens; + } + if (openAIServiceTier && typeof usage?.inputTokens === "number") { + // Avoid counting cached input tokens towards the input token count + // since service tier tokens are already priced differently + inputTokenDetails[openAIServiceTier] = + usage.inputTokens - + (inputTokenDetails[`${outputTokenDetailsKeyPrefix}cache_read`] ?? 0); + } + } + return inputTokenDetails; +} + +export function extractUsageMetadata(span?: { + status?: { code: number }; + attributes?: Record; +}) { + const isError = span?.status?.code === 2; + if (isError || !span || !span.attributes) { + return { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }; + } + + const usageMetadata: KVMap = { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + }; + + if ( + typeof span.attributes["ai.usage.promptTokens"] === "number" || + typeof span.attributes["ai.usage.inputTokens"] === "number" + ) { + usageMetadata.input_tokens = + span.attributes["ai.usage.promptTokens"] ?? + span.attributes["ai.usage.inputTokens"]; + } + + if ( + typeof span.attributes["ai.usage.completionTokens"] === "number" || + typeof span.attributes["ai.usage.outputTokens"] === "number" + ) { + usageMetadata.output_tokens = + span.attributes["ai.usage.completionTokens"] ?? + span.attributes["ai.usage.outputTokens"]; + } + + if (typeof span.attributes["ai.response.providerMetadata"] === "string") { + try { + const providerMetadata = JSON.parse( + span.attributes["ai.response.providerMetadata"], + ); + usageMetadata.input_token_details = extractInputTokenDetails( + typeof span.attributes["ai.usage.cachedInputTokens"] === "number" + ? { cachedInputTokens: span.attributes["ai.usage.cachedInputTokens"] } + : undefined, + providerMetadata, + ); + if ( + providerMetadata.anthropic != null && + typeof providerMetadata.anthropic === "object" + ) { + // AI SDK does not include Anthropic cache tokens in their stated input token + // numbers, so we need to add them manually + for (const key in usageMetadata.input_token_details) { + usageMetadata.input_tokens += usageMetadata.input_token_details[key]; + } + } + } catch { + // pass + } + } + usageMetadata.total_tokens = + usageMetadata.input_tokens + usageMetadata.output_tokens; + return usageMetadata; +} diff --git a/src/lib/utils/warn.ts b/src/lib/utils/warn.ts new file mode 100644 index 0000000..c582b24 --- /dev/null +++ b/src/lib/utils/warn.ts @@ -0,0 +1,37 @@ +const warnedMessages: Record = {}; + +/** + * Clear the warn-once record so a later call warns again. + * + * Test-only. `warnedMessages` is module-global and never otherwise reset, so + * without this a test asserting "warning X was (not) emitted" depends on whether + * an earlier test in the same file already consumed X. + * + * @internal + */ +export function _resetWarnedMessages(): void { + for (const key of Object.keys(warnedMessages)) { + delete warnedMessages[key]; + } +} + +export function warnOnce( + message: string, + options?: { type?: string; code?: string }, +): void { + const key = options?.code ?? message; + if (!warnedMessages[key]) { + warnedMessages[key] = true; + if ( + options?.type && + typeof process !== "undefined" && + typeof process.emitWarning === "function" + ) { + process.emitWarning(message, { type: options.type, code: options.code }); + } else if (options?.type && options?.code) { + console.warn(`${options.type} [${options.code}]: ${message}`); + } else { + console.warn(message); + } + } +} diff --git a/src/lib/utils/worker_threads.browser.ts b/src/lib/utils/worker_threads.browser.ts new file mode 100644 index 0000000..86dae84 --- /dev/null +++ b/src/lib/utils/worker_threads.browser.ts @@ -0,0 +1,17 @@ +/** + * worker_threads abstraction (browser / edge stub). + * + * Selected in browser / edge builds via the package.json `browser` field. + * Worker is null and WORKER_THREADS_AVAILABLE is false, so callers can + * bail out at construction time without referencing `node:worker_threads`. + * + * Intentionally does not import `node:worker_threads` -- bundlers that + * honor the browser field will never traverse the Node variant, so this + * file is what determines the browser-bundle surface. + */ + +export type WorkerLike = unknown; + +export const Worker = null; + +export const WORKER_THREADS_AVAILABLE = false; diff --git a/src/lib/utils/worker_threads.ts b/src/lib/utils/worker_threads.ts new file mode 100644 index 0000000..8d2cd4c --- /dev/null +++ b/src/lib/utils/worker_threads.ts @@ -0,0 +1,17 @@ +/** + * worker_threads abstraction (Node.js version). + * + * This file is swapped with worker_threads.browser.ts for browser / edge + * builds via the package.json `browser` field. Node gets the real module; + * browsers get a stub that signals unavailability. + * + * Only the surface actually used by SerializeWorker is re-exported. + */ + +import { Worker as NodeWorker } from "node:worker_threads"; + +export type WorkerLike = InstanceType; + +export const Worker: typeof NodeWorker | null = NodeWorker; + +export const WORKER_THREADS_AVAILABLE = true; diff --git a/src/lib/utils/xxhash/LICENSE b/src/lib/utils/xxhash/LICENSE new file mode 100644 index 0000000..f1fefa2 --- /dev/null +++ b/src/lib/utils/xxhash/LICENSE @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2019, i404788 +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/lib/utils/xxhash/xxhash.ts b/src/lib/utils/xxhash/xxhash.ts new file mode 100644 index 0000000..2447bbc --- /dev/null +++ b/src/lib/utils/xxhash/xxhash.ts @@ -0,0 +1,502 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck +// Original from: https://github.com/i404788/xxh3-ts +// Vendored for compatibility (remove Buffer use in favor of Uint8Array) +const n = (n: number | string) => BigInt(n); + +const PRIME32_1 = n("0x9E3779B1"); // 0b10011110001101110111100110110001 +const PRIME32_2 = n("0x85EBCA77"); // 0b10000101111010111100101001110111 +const PRIME32_3 = n("0xC2B2AE3D"); // 0b11000010101100101010111000111101 +const PRIME64_1 = n("0x9E3779B185EBCA87"); // 0b1001111000110111011110011011000110000101111010111100101010000111 +const PRIME64_2 = n("0xC2B2AE3D27D4EB4F"); // 0b1100001010110010101011100011110100100111110101001110101101001111 +const PRIME64_3 = n("0x165667B19E3779F9"); // 0b0001011001010110011001111011000110011110001101110111100111111001 +const PRIME64_4 = n("0x85EBCA77C2B2AE63"); // 0b1000010111101011110010100111011111000010101100101010111001100011 +const PRIME64_5 = n("0x27D4EB2F165667C5"); // 0b0010011111010100111010110010111100010110010101100110011111000101 +const PRIME_MX1 = n("0x165667919E3779F9"); // 0b0001011001010110011001111001000110011110001101110111100111111001 +const PRIME_MX2 = n("0x9FB21C651E98DF25"); // 0b1001111110110010000111000110010100011110100110001101111100100101 + +// Convert hex string to Uint8Array +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} + +const kkey = hexToBytes( + "b8fe6c3923a44bbe7c01812cf721ad1cded46de9839097db7240a4a4b7b3671fcb79e64eccc0e578825ad07dccff7221b8084674f743248ee03590e6813a264c3c2852bb91c300cb88d0658b1b532ea371644897a20df94e3819ef46a9deacd8a8fa763fe39c343ff9dcbbc7c70b4f1d8a51e04bcdb45931c89f7ec9d9787364eac5ac8334d3ebc3c581a0fffa1363eb170ddd51b7f0da49d316552629d4689e2b16be587d47a1fc8ff8b8d17ad031ce45cb3a8f95160428afd7fbcabb4b407e", +); +const mask128 = (n(1) << n(128)) - n(1); +const mask64 = (n(1) << n(64)) - n(1); +const mask32 = (n(1) << n(32)) - n(1); +const STRIPE_LEN = 64; +const ACC_NB = STRIPE_LEN / 8; +const _U64 = 8; +const _U32 = 4; + +// Create a view into a Uint8Array at an offset +function getView(buf: Uint8Array, offset = 0): Uint8Array { + return new Uint8Array( + buf.buffer, + buf.byteOffset + offset, + buf.length - offset, + ); +} + +// Read BigInt from Uint8Array in little-endian +function readBigUInt64LE(buf: Uint8Array, offset = 0): bigint { + const view = new DataView(buf.buffer, buf.byteOffset + offset); + return view.getBigUint64(0, true); +} + +// Read UInt32 from Uint8Array in little-endian +function readUInt32LE(buf: Uint8Array, offset = 0): number { + const view = new DataView(buf.buffer, buf.byteOffset + offset); + return view.getUint32(0, true); +} + +// Read UInt8 from Uint8Array +function readUInt8(buf: Uint8Array, offset = 0): number { + return buf[offset]; +} + +const bswap64 = (a: bigint) => { + return ( + ((a & n(0xff)) << n(56)) | + ((a & n(0xff00)) << n(40)) | + ((a & n(0xff0000)) << n(24)) | + ((a & n(0xff000000)) << n(8)) | + ((a & n(0xff00000000)) >> n(8)) | + ((a & n(0xff0000000000)) >> n(24)) | + ((a & n(0xff000000000000)) >> n(40)) | + ((a & n(0xff00000000000000)) >> n(56)) + ); +}; + +const bswap32 = (a: bigint) => { + a = ((a & n(0x0000ffff)) << n(16)) | ((a & n(0xffff0000)) >> n(16)); + a = ((a & n(0x00ff00ff)) << n(8)) | ((a & n(0xff00ff00)) >> n(8)); + return a; +}; + +const XXH_mult32to64 = (a: bigint, b: bigint) => + ((a & mask32) * (b & mask32)) & mask64; +const assert = (a: boolean) => { + if (!a) throw new Error("Assert failed"); +}; + +function rotl32(a: bigint, b: bigint) { + return ((a << b) | (a >> (n(32) - b))) & mask32; +} + +function XXH3_accumulate_512( + acc: BigUint64Array, + data: Uint8Array, + key: Uint8Array, +) { + for (let i = 0; i < ACC_NB; i++) { + const data_val = readBigUInt64LE(data, i * 8); + const data_key = data_val ^ readBigUInt64LE(key, i * 8); + acc[i ^ 1] += data_val; + acc[i] += XXH_mult32to64(data_key, data_key >> n(32)); + } + return acc; +} + +function XXH3_accumulate( + acc: BigUint64Array, + data: Uint8Array, + key: Uint8Array, + nbStripes: number, +) { + for (let n = 0; n < nbStripes; n++) { + XXH3_accumulate_512( + acc, + getView(data, n * STRIPE_LEN), + getView(key, n * 8), + ); + } + return acc; +} + +function XXH3_scrambleAcc(acc: BigUint64Array, key: Uint8Array) { + for (let i = 0; i < ACC_NB; i++) { + const key64 = readBigUInt64LE(key, i * 8); + let acc64 = acc[i]; + acc64 = xorshift64(acc64, n(47)); + acc64 ^= key64; + acc64 *= PRIME32_1; + acc[i] = acc64 & mask64; + } + return acc; +} + +function XXH3_mix2Accs(acc: BigUint64Array, key: Uint8Array) { + return XXH3_mul128_fold64( + acc[0] ^ readBigUInt64LE(key, 0), + acc[1] ^ readBigUInt64LE(key, _U64), + ); +} + +function XXH3_mergeAccs(acc: BigUint64Array, key: Uint8Array, start: bigint) { + let result64 = start; + + result64 += XXH3_mix2Accs(acc.slice(0), getView(key, 0 * _U32)); + result64 += XXH3_mix2Accs(acc.slice(2), getView(key, 4 * _U32)); + result64 += XXH3_mix2Accs(acc.slice(4), getView(key, 8 * _U32)); + result64 += XXH3_mix2Accs(acc.slice(6), getView(key, 12 * _U32)); + + return XXH3_avalanche(result64 & mask64); +} + +function XXH3_hashLong( + acc: BigUint64Array, + data: Uint8Array, + secret: Uint8Array, + f_acc: ( + acc: BigUint64Array, + data: Uint8Array, + key: Uint8Array, + ) => BigUint64Array, + f_scramble: (acc: BigUint64Array, key: Uint8Array) => BigUint64Array, +) { + const nbStripesPerBlock = Math.floor((secret.byteLength - STRIPE_LEN) / 8); + const block_len = STRIPE_LEN * nbStripesPerBlock; + const nb_blocks = Math.floor((data.byteLength - 1) / block_len); + + for (let n = 0; n < nb_blocks; n++) { + acc = XXH3_accumulate( + acc, + getView(data, n * block_len), + secret, + nbStripesPerBlock, + ); + acc = f_scramble(acc, getView(secret, secret.byteLength - STRIPE_LEN)); + } + + { + // Partial block + const nbStripes = Math.floor( + (data.byteLength - 1 - block_len * nb_blocks) / STRIPE_LEN, + ); + acc = XXH3_accumulate( + acc, + getView(data, nb_blocks * block_len), + secret, + nbStripes, + ); + + // Last Stripe + acc = f_acc( + acc, + getView(data, data.byteLength - STRIPE_LEN), + getView(secret, secret.byteLength - STRIPE_LEN - 7), + ); + } + return acc; +} + +function XXH3_hashLong_128b( + data: Uint8Array, + secret: Uint8Array, + seed: bigint, +) { + let acc = new BigUint64Array([ + PRIME32_3, + PRIME64_1, + PRIME64_2, + PRIME64_3, + PRIME64_4, + PRIME32_2, + PRIME64_5, + PRIME32_1, + ]); + assert(data.length > 128); + + acc = XXH3_hashLong(acc, data, secret, XXH3_accumulate_512, XXH3_scrambleAcc); + + /* converge into final hash */ + assert(acc.length * 8 == 64); + { + const low64 = XXH3_mergeAccs( + acc, + getView(secret, 11), + (n(data.byteLength) * PRIME64_1) & mask64, + ); + const high64 = XXH3_mergeAccs( + acc, + getView(secret, secret.byteLength - STRIPE_LEN - 11), + ~(n(data.byteLength) * PRIME64_2) & mask64, + ); + return (high64 << n(64)) | low64; + } +} + +function XXH3_mul128_fold64(a: bigint, b: bigint) { + const lll = (a * b) & mask128; + return (lll & mask64) ^ (lll >> n(64)); +} + +function XXH3_mix16B(data: Uint8Array, key: Uint8Array, seed: bigint) { + return XXH3_mul128_fold64( + (readBigUInt64LE(data, 0) ^ (readBigUInt64LE(key, 0) + seed)) & mask64, + (readBigUInt64LE(data, 8) ^ (readBigUInt64LE(key, 8) - seed)) & mask64, + ); +} + +function XXH3_mix32B( + acc: bigint, + data1: Uint8Array, + data2: Uint8Array, + key: Uint8Array, + seed: bigint, +) { + let accl = acc & mask64; + let acch = (acc >> n(64)) & mask64; + accl += XXH3_mix16B(data1, key, seed); + accl ^= readBigUInt64LE(data2, 0) + readBigUInt64LE(data2, 8); + accl &= mask64; + acch += XXH3_mix16B(data2, getView(key, 16), seed); + acch ^= readBigUInt64LE(data1, 0) + readBigUInt64LE(data1, 8); + acch &= mask64; + return (acch << n(64)) | accl; +} + +function XXH3_avalanche(h64: bigint) { + h64 ^= h64 >> n(37); + h64 *= PRIME_MX1; + h64 &= mask64; + h64 ^= h64 >> n(32); + return h64; +} + +function XXH3_avalanche64(h64: bigint) { + h64 ^= h64 >> n(33); + h64 *= PRIME64_2; + h64 &= mask64; + h64 ^= h64 >> n(29); + h64 *= PRIME64_3; + h64 &= mask64; + h64 ^= h64 >> n(32); + return h64; +} + +function XXH3_len_1to3_128b(data: Uint8Array, key32: Uint8Array, seed: bigint) { + const len = data.byteLength; + assert(len > 0 && len <= 3); + + const combined = + n(readUInt8(data, len - 1)) | + n(len << 8) | + n(readUInt8(data, 0) << 16) | + n(readUInt8(data, len >> 1) << 24); + const blow = (n(readUInt32LE(key32, 0)) ^ n(readUInt32LE(key32, 4))) + seed; + const low = (combined ^ blow) & mask64; + const bhigh = (n(readUInt32LE(key32, 8)) ^ n(readUInt32LE(key32, 12))) - seed; + const high = (rotl32(bswap32(combined), n(13)) ^ bhigh) & mask64; + + return ((XXH3_avalanche64(high) & mask64) << n(64)) | XXH3_avalanche64(low); +} + +function xorshift64(b: bigint, shift: bigint) { + return b ^ (b >> shift); +} + +function XXH3_len_4to8_128b(data: Uint8Array, key32: Uint8Array, seed: bigint) { + const len = data.byteLength; + assert(len >= 4 && len <= 8); + { + const l1 = readUInt32LE(data, 0); + const l2 = readUInt32LE(data, len - 4); + const l64 = n(l1) | (n(l2) << n(32)); + const bitflip = + ((readBigUInt64LE(key32, 16) ^ readBigUInt64LE(key32, 24)) + seed) & + mask64; + const keyed = l64 ^ bitflip; + let m128 = (keyed * (PRIME64_1 + (n(len) << n(2)))) & mask128; + m128 += (m128 & mask64) << n(65); + m128 &= mask128; + m128 ^= m128 >> n(67); + + return ( + xorshift64( + (xorshift64(m128 & mask64, n(35)) * PRIME_MX2) & mask64, + n(28), + ) | + (XXH3_avalanche(m128 >> n(64)) << n(64)) + ); + } +} + +function XXH3_len_9to16_128b( + data: Uint8Array, + key64: Uint8Array, + seed: bigint, +) { + const len = data.byteLength; + assert(len >= 9 && len <= 16); + { + const bitflipl = + ((readBigUInt64LE(key64, 32) ^ readBigUInt64LE(key64, 40)) + seed) & + mask64; + const bitfliph = + ((readBigUInt64LE(key64, 48) ^ readBigUInt64LE(key64, 56)) - seed) & + mask64; + const ll1 = readBigUInt64LE(data); + let ll2 = readBigUInt64LE(data, len - 8); + + let m128 = (ll1 ^ ll2 ^ bitflipl) * PRIME64_1; + + const m128_l = (m128 & mask64) + (n(len - 1) << n(54)); + m128 = (m128 & (mask128 ^ mask64)) | m128_l; // eqv. to adding only to lower 64b + ll2 ^= bitfliph; + + m128 += (ll2 + (ll2 & mask32) * (PRIME32_2 - n(1))) << n(64); + m128 &= mask128; + m128 ^= bswap64(m128 >> n(64)); + let h128 = (m128 & mask64) * PRIME64_2; + h128 += ((m128 >> n(64)) * PRIME64_2) << n(64); + h128 &= mask128; + + return ( + XXH3_avalanche(h128 & mask64) | (XXH3_avalanche(h128 >> n(64)) << n(64)) + ); + } +} + +function XXH3_len_0to16_128b(data: Uint8Array, seed: bigint) { + const len = data.byteLength; + assert(len <= 16); + if (len > 8) return XXH3_len_9to16_128b(data, kkey, seed); + if (len >= 4) return XXH3_len_4to8_128b(data, kkey, seed); + if (len > 0) return XXH3_len_1to3_128b(data, kkey, seed); + return ( + XXH3_avalanche64( + seed ^ readBigUInt64LE(kkey, 64) ^ readBigUInt64LE(kkey, 72), + ) | + (XXH3_avalanche64( + seed ^ readBigUInt64LE(kkey, 80) ^ readBigUInt64LE(kkey, 88), + ) << + n(64)) + ); +} + +function inv64(x: bigint) { + // NOTE: `AND` fixes signedness (but because of 2's complement we need to re-add 1) + return (~x + n(1)) & mask64; +} + +function XXH3_len_17to128_128b( + data: Uint8Array, + secret: Uint8Array, + seed: bigint, +) { + let acc = (n(data.byteLength) * PRIME64_1) & mask64; + let i = n(data.byteLength - 1) / n(32); + while (i >= 0) { + const ni = Number(i); + acc = XXH3_mix32B( + acc, + getView(data, 16 * ni), + getView(data, data.byteLength - 16 * (ni + 1)), + getView(secret, 32 * ni), + seed, + ); + i--; + } + + let h128l = (acc + (acc >> n(64))) & mask64; + h128l = XXH3_avalanche(h128l); + let h128h = + (acc & mask64) * PRIME64_1 + + (acc >> n(64)) * PRIME64_4 + + ((n(data.byteLength) - seed) & mask64) * PRIME64_2; + h128h &= mask64; + + h128h = inv64(XXH3_avalanche(h128h)); + return h128l | (h128h << n(64)); +} + +function XXH3_len_129to240_128b( + data: Uint8Array, + secret: Uint8Array, + seed: bigint, +) { + let acc = (n(data.byteLength) * PRIME64_1) & mask64; + for (let i = 32; i < 160; i += 32) { + acc = XXH3_mix32B( + acc, + getView(data, i - 32), + getView(data, i - 16), + getView(secret, i - 32), + seed, + ); + } + acc = XXH3_avalanche(acc & mask64) | (XXH3_avalanche(acc >> n(64)) << n(64)); + for (let i = 160; i <= data.byteLength; i += 32) { + acc = XXH3_mix32B( + acc, + getView(data, i - 32), + getView(data, i - 16), + getView(secret, 3 + i - 160), + seed, + ); + } + acc = XXH3_mix32B( + acc, + getView(data, data.byteLength - 16), + getView(data, data.byteLength - 32), + getView(secret, 136 - 17 - 16), + inv64(seed), + ); + + let h128l = (acc + (acc >> n(64))) & mask64; + h128l = XXH3_avalanche(h128l); + let h128h = + (acc & mask64) * PRIME64_1 + + (acc >> n(64)) * PRIME64_4 + + ((n(data.byteLength) - seed) & mask64) * PRIME64_2; + h128h &= mask64; + + h128h = inv64(XXH3_avalanche(h128h)); + return h128l | (h128h << n(64)); +} + +/** + * Compute XXH3 128-bit hash of the input data. + * + * @param data - Input data as Uint8Array + * @param seed - Optional seed value (default: 0) + * @returns 128-bit hash as a single BigInt (high 64 bits << 64 | low 64 bits) + */ +export function XXH3_128(data: Uint8Array, seed: bigint = n(0)): bigint { + const len = data.byteLength; + if (len <= 16) return XXH3_len_0to16_128b(data, seed); + if (len <= 128) return XXH3_len_17to128_128b(data, kkey, seed); + if (len <= 240) return XXH3_len_129to240_128b(data, kkey, seed); + + return XXH3_hashLong_128b(data, kkey, seed); +} + +/** + * Convert a 128-bit hash (BigInt) to a 16-byte Uint8Array. + * + * @param hash128 - 128-bit hash as BigInt + * @returns 16-byte Uint8Array in little-endian byte order + */ +export function xxh128ToBytes(hash128: bigint): Uint8Array { + const result = new Uint8Array(16); + const view = new DataView(result.buffer); + + // Extract low and high 64-bit values + const low64 = hash128 & mask64; + const high64 = hash128 >> n(64); + + // Write in big-endian order to match Python's xxhash.digest() output + // Python outputs: [high64 bytes][low64 bytes], each in big-endian + view.setBigUint64(0, high64, false); // high 64 bits, big-endian + view.setBigUint64(8, low64, false); // low 64 bits, big-endian + + return result; +} diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 0000000..2c2d613 --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,38 @@ +import { v7 as uuidv7 } from "./utils/uuid/src/index.js"; +import { + assertUuid, + getUuidVersion, + nonCryptographicUuid7Deterministic, +} from "./utils/_uuid.js"; + +export { uuid7FromTime } from "./utils/_uuid.js"; + +/** + * Generate a random UUID v7 string. + */ +export function uuid7(): string { + return uuidv7(); +} + +/** + * Compute the run ID used for a secondary tracing replica. + * + * @param runId - The original UUID v7 run ID. + * @param projectName - The secondary replica's destination project name. + * @returns The run ID used in the secondary replica destination. + */ +export function computeRunIdForSecondaryReplica( + runId: string, + projectName: string, +): string { + if (typeof projectName !== "string" || projectName.length === 0) { + throw new Error("projectName must be a non-empty string"); + } + + assertUuid(runId, "runId"); + const normalizedRunId = runId.toLowerCase(); + if (getUuidVersion(normalizedRunId) !== 7) { + throw new Error("runId must be a UUID v7"); + } + return nonCryptographicUuid7Deterministic(normalizedRunId, projectName); +} diff --git a/src/lib/vitest/index.mts b/src/lib/vitest/index.mts new file mode 100644 index 0000000..37c9f26 --- /dev/null +++ b/src/lib/vitest/index.mts @@ -0,0 +1,347 @@ +import { + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +} from "../utils/jestlike/matchers.js"; +import type { LangSmithJestlikeWrapperParams } from "../utils/jestlike/types.js"; +import { wrapVitest } from "./utils/wrapper.js"; +import { importVitestModule } from "./utils/esm.mjs"; + +const vitestModule = await importVitestModule(); + +const { + expect: vitestExpect, + test: vitestTest, + it: vitestIt, + describe: vitestDescribe, + beforeAll: vitestBeforeAll, + afterAll: vitestAfterAll, + beforeEach: vitestBeforeEach, + afterEach: vitestAfterEach, +} = vitestModule; + +vitestExpect.extend({ + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +}); + +const { test, it, describe, expect, logFeedback, logOutputs, wrapEvaluator } = + wrapVitest({ + expect: vitestExpect, + it: vitestIt, + test: vitestTest, + describe: vitestDescribe, + beforeAll: vitestBeforeAll, + afterAll: vitestAfterAll, + beforeEach: vitestBeforeEach, + afterEach: vitestAfterEach, + }); + +export { + /** + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + test, + /** + * Alias of `ls.test()`. + * + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.it( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.it.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + it, + /** + * Defines a LangSmith test suite. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. + * + * Should contain `ls.test()` cases within. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test suite + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {Partial} [config] - Config to use when tracing/sending results. + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + describe, + /** + * Wrapped `expect` with additional matchers for directly logging feedback and + * other convenient string matchers. + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will assert evaluator's returned score + * // and log feedback. + * await ls.expect(response).evaluatedBy(myEvaluator).toBeGreaterThan(0.5); + * return { response }; + * } + * ); + * }); + * ``` + */ + expect, + /** + * Log feedback associated with the current test, usually generated by some kind of + * evaluator. + * + * Logged feedback will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * }); + * ``` + */ + logFeedback, + /** + * Log output associated with the current test. + * + * Logged output will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * If a value is returned from your test case, it will override + * manually logged output. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * ls.logOutputs({ response }); + * } + * ); + * }); + * ``` + */ + logOutputs, + /** + * Wraps an evaluator function, adding tracing and logging it to a + * separate project to avoid polluting test traces with evaluator runs. + * + * The wrapped evaluator must take only a single argument as input. + * + * If the wrapped evaluator returns an object with + * `{ key: string, score: number | boolean }`, the function returned from this + * method will automatically log the key and score as feedback on the current run. + * Otherwise, you should call {@link logFeedback} with some transformed version + * of the result of running the evaluator. + * + * @param {Function} evaluator The evaluator to be wrapped. Must take only a single argument as input. + * + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will log the evaluator's returned score + * // and as feedback under the returned key. + * const wrappedEvaluator = ls.wrapEvaluator(myEvaluator); + * await wrappedEvaluator({ inputs, referenceOutputs, actual: response }); + * return { response }; + * } + * ); + * }); + * ``` + */ + wrapEvaluator, + type LangSmithJestlikeWrapperParams, + wrapVitest, +}; + +export * from "../utils/jestlike/types.js"; diff --git a/src/lib/vitest/index.ts b/src/lib/vitest/index.ts new file mode 100644 index 0000000..d051aab --- /dev/null +++ b/src/lib/vitest/index.ts @@ -0,0 +1,345 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +import { + expect as vitestExpect, + test as vitestTest, + it as vitestIt, + describe as vitestDescribe, + beforeAll as vitestBeforeAll, + afterAll as vitestAfterAll, + beforeEach as vitestBeforeEach, + afterEach as vitestAfterEach, +} from "vitest"; +import { + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +} from "../utils/jestlike/matchers.js"; +import type { LangSmithJestlikeWrapperParams } from "../utils/jestlike/types.js"; +import { wrapVitest } from "./utils/wrapper.js"; + +vitestExpect.extend({ + toBeRelativeCloseTo, + toBeAbsoluteCloseTo, + toBeSemanticCloseTo, +}); + +const { test, it, describe, expect, logFeedback, logOutputs, wrapEvaluator } = + wrapVitest({ + expect: vitestExpect, + it: vitestIt, + test: vitestTest, + describe: vitestDescribe, + beforeAll: vitestBeforeAll, + afterAll: vitestAfterAll, + beforeEach: vitestBeforeEach, + afterEach: vitestAfterEach, + }); + +export { + /** + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + test, + /** + * Alias of `ls.test()`. + * + * Defines a LangSmith test case within a suite. Takes an additional `lsParams` + * arg containing example inputs and reference outputs for your evaluated app. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. You can also iterate over several + * examples at once with `ls.test.each([])` (see below example). + * + * Must be wrapped within an `ls.describe()` block. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * Returning a value from the wrapped test function is the same as logging it as + * the experiment example result. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test case + * @param {LangSmithJestlikeWrapperParams} lsParams Input and output for the eval, + * as well as additional LangSmith fields + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {number} [timeout] - Optional timeout in milliseconds for the test + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.it( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.it.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + it, + /** + * Defines a LangSmith test suite. + * + * When run, will create a dataset and experiment in LangSmith, then send results + * and log feedback if tracing is enabled. + * + * Should contain `ls.test()` cases within. The describe block + * corresponds to a dataset created on LangSmith, while test cases correspond to + * individual examples within the dataset. Running the test is analogous to an experiment. + * + * You can manually disable creating experiments in LangSmith for purely local testing by + * setting `LANGSMITH_TEST_TRACKING="false"` as an environment variable. + * + * @param {string} name - The name or description of the test suite + * @param {Function} fn - The function containing the test implementation. + * Will receive "inputs" and "referenceOutputs" from parameters. + * Returning a value here will populate experiment output logged in LangSmith. + * @param {Partial} [config] - Config to use when tracing/sending results. + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * + * ls.test.each([ + * { inputs: {...}, referenceOutputs: {...} }, + * { inputs: {...}, referenceOutputs: {...} } + * ])("Should respond to the above examples", async ({ inputs, referenceOutputs }) => { + * ... + * }); + * }); + * ``` + */ + describe, + /** + * Wrapped `expect` with additional matchers for directly logging feedback and + * other convenient string matchers. + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will assert evaluator's returned score + * // and log feedback. + * await ls.expect(response).evaluatedBy(myEvaluator).toBeGreaterThan(0.5); + * return { response }; + * } + * ); + * }); + * ``` + */ + expect, + /** + * Log feedback associated with the current test, usually generated by some kind of + * evaluator. + * + * Logged feedback will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * const { key, score } = await someEvaluator({ response }, referenceOutputs); + * ls.logFeedback({ key, score }); + * return { response }; + * } + * ); + * }); + * ``` + */ + logFeedback, + /** + * Log output associated with the current test. + * + * Logged output will appear in test results if custom reporting is enabled, + * as well as in experiment results in LangSmith. + * + * If a value is returned from your test case, it will override + * manually logged output. + * + * @param {EvaluationResult} feedback Feedback to log + * @param {string} feedback.key The name of the feedback metric + * @param {number | boolean} feedback.key The value of the feedback + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * ls.logOutputs({ response }); + * } + * ); + * }); + * ``` + */ + logOutputs, + /** + * Wraps an evaluator function, adding tracing and logging it to a + * separate project to avoid polluting test traces with evaluator runs. + * + * The wrapped evaluator must take only a single argument as input. + * + * If the wrapped evaluator returns an object with + * `{ key: string, score: number | boolean }`, the function returned from this + * method will automatically log the key and score as feedback on the current run. + * Otherwise, you should call {@link logFeedback} with some transformed version + * of the result of running the evaluator. + * + * @param {Function} evaluator The evaluator to be wrapped. Must take only a single argument as input. + * + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * // Alternative to logFeedback that will log the evaluator's returned score + * // and as feedback under the returned key. + * const wrappedEvaluator = ls.wrapEvaluator(myEvaluator); + * await wrappedEvaluator({ inputs, referenceOutputs, actual: response }); + * return { response }; + * } + * ); + * }); + * ``` + */ + wrapEvaluator, + type LangSmithJestlikeWrapperParams, + wrapVitest, +}; + +export * from "../utils/jestlike/types.js"; diff --git a/src/lib/vitest/reporter.mts b/src/lib/vitest/reporter.mts new file mode 100644 index 0000000..1b3c766 --- /dev/null +++ b/src/lib/vitest/reporter.mts @@ -0,0 +1,35 @@ +import { + printVitestReporterTable, + printVitestTestModulesReporterTable, + type VitestTestModule, +} from "./utils/reporter.js"; +import { importVitestModule } from "./utils/esm.mjs"; + +const vitestReporters = await importVitestModule("reporters"); +const DefaultReporter = vitestReporters.DefaultReporter; + +class LangSmithEvalReporter extends DefaultReporter { + private skipOnFinished = false; + + async onFinished(files: unknown[], errors: unknown[]) { + super.onFinished(files, errors); + + // Vitest 3.x will call `onFinished` after `onTestRunEnd`, + // thus we need to gate this to avoid double printing. + if (this.skipOnFinished) return; + await printVitestReporterTable(files, this.ctx); + } + + // `onFinished` is removed in Vitest 4.x, so we use `onTestRunEnd` instead. + async onTestRunEnd( + testModules: VitestTestModule[], + unhandledErrors: { message: string; name?: string }[], + reason: "passed" | "interrupted" | "failed", + ) { + super.onTestRunEnd(testModules, unhandledErrors, reason); + this.skipOnFinished = true; + await printVitestTestModulesReporterTable(testModules); + } +} + +export default LangSmithEvalReporter; diff --git a/src/lib/vitest/reporter.ts b/src/lib/vitest/reporter.ts new file mode 100644 index 0000000..406a1fe --- /dev/null +++ b/src/lib/vitest/reporter.ts @@ -0,0 +1,32 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore Import throws an error in internal CJS build, but seems to work fine after build +import { DefaultReporter } from "vitest/reporters"; + +import { RunnerTestFile } from "vitest"; +import { + printVitestReporterTable, + printVitestTestModulesReporterTable, +} from "./utils/reporter.js"; + +class LangSmithEvalReporter extends DefaultReporter { + private skipOnFinished = false; + + async onFinished(files: RunnerTestFile[], errors: unknown[]) { + const onFinished = ( + DefaultReporter.prototype as unknown as { + onFinished?: (files: RunnerTestFile[], errors: unknown[]) => void; + } + ).onFinished; + onFinished?.call(this, files, errors); + if (this.skipOnFinished) return; + await printVitestReporterTable(files, this.ctx); + } + + async onTestRunEnd(...args: Parameters) { + super.onTestRunEnd(...args); + this.skipOnFinished = true; + await printVitestTestModulesReporterTable(args[0]); + } +} + +export default LangSmithEvalReporter; diff --git a/src/lib/vitest/utils/esm.mts b/src/lib/vitest/utils/esm.mts new file mode 100644 index 0000000..20f965b --- /dev/null +++ b/src/lib/vitest/utils/esm.mts @@ -0,0 +1,43 @@ +import { createRequire } from "module"; +import { pathToFileURL } from "url"; +import { sep } from "path"; + +/** + * Resolve vitest ESM module from correct workspace + * This is necessary for monorepos where langsmith is hoisted to + * the top level of the monorepo but Vitest is not. + * This can occur if you have multiple versions of vitest installed in the monorepo + * and there is only one shared, hoisted version of langsmith. + */ +export const importVitestModule = async (entrypoint?: string) => { + let importedModule; + try { + const require = createRequire(import.meta.url); + // Find the package.json to get the correct ESM entry point + const packagePath = require.resolve(`vitest/package.json`, { + paths: [process.cwd()], + }); + const pkg = require(packagePath); + const pkgDir = packagePath.replace(`${sep}package.json`, ""); + const esmEntry = !entrypoint + ? pkg.module ?? pkg.exports?.["."]?.import ?? "dist/index.js" + : pkg.exports?.[`./${entrypoint}`]?.import ?? + pkg.exports?.[`./${entrypoint}`]?.default; + const modulePath = `${pkgDir}${sep}${esmEntry}`; + const path = pathToFileURL(modulePath).href; + importedModule = await import(path); + if (!importedModule) { + throw new Error( + `Failed to import vitest entrypoint${ + entrypoint ? ` ${entrypoint}` : "" + }`, + ); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (_error: any) { + importedModule = await import( + `vitest${entrypoint ? `/${entrypoint}` : ""}` + ); + } + return importedModule; +}; diff --git a/src/lib/vitest/utils/reporter.ts b/src/lib/vitest/utils/reporter.ts new file mode 100644 index 0000000..37bb67f --- /dev/null +++ b/src/lib/vitest/utils/reporter.ts @@ -0,0 +1,56 @@ +import { printReporterTable } from "../../utils/jestlike/reporter.js"; + +// Can't use types here because of module resolution issues +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const printVitestReporterTable = async (files: any, ctx: any) => { + for (const file of files) { + for (const task of file.tasks) { + const testModule = ctx.state.getReportedEntity(task); + const tests = [...testModule.children.allTests()].map((test) => { + return { + title: test.name, + status: test.result()?.state ?? "skipped", + duration: Math.round(test.diagnostic()?.duration ?? 0), + }; + }); + const result = ["pass", "fail", "skip"].includes(task.result?.state ?? "") + ? task.result?.state + : "skip"; + await printReporterTable(task.name, tests, result); + } + } +}; + +// Plucked from Vitest 4.x internal types +export interface VitestTestModule { + children: { + allTests: () => Iterable<{ + name: string; + result: () => { state: "pending" | "passed" | "failed" | "skipped" }; + diagnostic: () => { duration: number } | undefined; + }>; + }; + state: () => "pending" | "queued" | "skipped" | "passed" | "failed"; + relativeModuleId: string; +} + +export const printVitestTestModulesReporterTable = async ( + testModules: readonly VitestTestModule[], +) => { + for (const testModule of testModules) { + const tests = [...testModule.children.allTests()].map((test) => { + return { + title: test.name, + status: test.result()?.state ?? "skipped", + duration: Math.round(test.diagnostic()?.duration ?? 0), + }; + }); + + const state = testModule.state(); + await printReporterTable( + testModule.relativeModuleId, + tests, + state === "pending" || state === "queued" ? "skipped" : state, + ); + } +}; diff --git a/src/lib/vitest/utils/wrapper.ts b/src/lib/vitest/utils/wrapper.ts new file mode 100644 index 0000000..e04bbc3 --- /dev/null +++ b/src/lib/vitest/utils/wrapper.ts @@ -0,0 +1,146 @@ +import type { Assertion } from "vitest"; +import { + type AbsoluteCloseToMatcherOptions, + type SemanticCloseToMatcherOptions, + type RelativeCloseToMatcherOptions, +} from "../../utils/jestlike/matchers.js"; +import type { SimpleEvaluator } from "../../utils/jestlike/vendor/evaluatedBy.js"; +import { generateWrapperFromJestlikeMethods } from "../../utils/jestlike/index.js"; +import { logFeedback, logOutputs } from "../../utils/jestlike/index.js"; +import { wrapEvaluator } from "../../utils/jestlike/vendor/evaluatedBy.js"; + +interface CustomMatchers { + toBeRelativeCloseTo( + expected: string, + options?: RelativeCloseToMatcherOptions, + ): Promise; + toBeAbsoluteCloseTo( + expected: string, + options?: AbsoluteCloseToMatcherOptions, + ): Promise; + toBeSemanticCloseTo( + expected: string, + options?: SemanticCloseToMatcherOptions, + ): Promise; + /** + * Matcher that runs an evaluator with actual outputs and reference outputs from some run, + * and asserts the evaluator's output `score` based on subsequent matchers. + * Will also log feedback to LangSmith and to test results. + * + * Inputs come from the inputs passed to the test. + * + * @example + * ```ts + * import * as ls from "langsmith/vitest"; + * + * const myEvaluator = async ({ inputs, actual, referenceOutputs }) => { + * // Judge example on some metric + * return { + * key: "quality", + * score: 0.7, + * }; + * }; + * + * ls.describe("Harmfulness dataset", async () => { + * ls.test( + * "Should not respond to a toxic query", + * { + * inputs: { query: "How do I do something evil?" }, + * referenceOutputs: { response: "I do not respond to those queries!" } + * }, + * ({ inputs, referenceOutputs }) => { + * const response = await myApp(inputs); + * await ls.expect(response).evaluatedBy(myEvaluator).toBeGreaterThan(0.5); + * return { response }; + * } + * ); + * }); + * ``` + */ + evaluatedBy(evaluator: SimpleEvaluator): Assertion> & { + not: Assertion>; + resolves: Assertion>; + rejects: Assertion>; + }; +} + +declare module "vitest" { + interface Assertion extends CustomMatchers {} + interface AsymmetricMatchersContaining extends CustomMatchers {} +} + +/** + * Dynamically wrap original Vitest imports. + * + * This may be necessary to ensure you are wrapping the correct + * Vitest version if you are using a monorepo whose workspaces + * use multiple versions of Vitest. + * + * @param originalVitestMethods - The original Vitest imports to wrap. + * @returns The wrapped Vitest imports. + * See https://docs.smith.langchain.com/evaluation/how_to_guides/vitest_jest + * for more details. + */ +export const wrapVitest = (originalVitestMethods: Record) => { + if ( + typeof originalVitestMethods !== "object" || + originalVitestMethods == null + ) { + throw new Error("originalVitestMethods must be an non-null object."); + } + if ( + !("expect" in originalVitestMethods) || + typeof originalVitestMethods.expect !== "function" + ) { + throw new Error("Your passed object must contain a `expect` method."); + } + if ( + !("it" in originalVitestMethods) || + typeof originalVitestMethods.it !== "function" + ) { + throw new Error("Your passed object must contain a `it` method."); + } + if ( + !("test" in originalVitestMethods) || + typeof originalVitestMethods.test !== "function" + ) { + throw new Error("Your passed object must contain a `test` method."); + } + if ( + !("describe" in originalVitestMethods) || + typeof originalVitestMethods.describe !== "function" + ) { + throw new Error("Your passed object must contain a `describe` method."); + } + if ( + !("beforeAll" in originalVitestMethods) || + typeof originalVitestMethods.beforeAll !== "function" + ) { + throw new Error("Your passed object must contain a `beforeAll` method."); + } + if ( + !("afterAll" in originalVitestMethods) || + typeof originalVitestMethods.afterAll !== "function" + ) { + throw new Error("Your passed object must contain a `afterAll` method."); + } + + const wrappedMethods = generateWrapperFromJestlikeMethods( + { + expect: originalVitestMethods.expect, + it: originalVitestMethods.it, + test: originalVitestMethods.test, + describe: originalVitestMethods.describe, + beforeAll: originalVitestMethods.beforeAll, + afterAll: originalVitestMethods.afterAll, + }, + "vitest", + ); + + return { + ...wrappedMethods, + logFeedback, + logOutputs, + wrapEvaluator, + }; +}; diff --git a/src/lib/wrappers/anthropic.ts b/src/lib/wrappers/anthropic.ts new file mode 100644 index 0000000..50476d0 --- /dev/null +++ b/src/lib/wrappers/anthropic.ts @@ -0,0 +1,576 @@ +import type Anthropic from "@anthropic-ai/sdk"; +import type { Stream } from "@anthropic-ai/sdk/streaming"; +import type { MessageStream } from "@anthropic-ai/sdk/lib/MessageStream"; +import type { RunTreeConfig } from "../index.js"; +import { wrapManagedAgentSessionEvents } from "./utils/anthropic_managed.js"; +import { + isTraceableFunction, + traceable, + TraceableConfig, +} from "../traceable.js"; +import { KVMap } from "../schemas.js"; +import { convertAnthropicUsageToInputTokenDetails } from "../utils/usage.js"; +import { mask, overParams, redactOutside } from "../utils/redaction.js"; + +const TRACED_INVOCATION_KEYS = ["top_k", "top_p", "stream", "thinking"]; + +/** Keys of an MCP server definition that are safe to trace. */ +const MCP_SERVER_SAFE_KEYS = new Set([ + "name", + "type", + "url", + "tool_configuration", +]); + +/** Mask `mcp_servers` credentials outside {@link MCP_SERVER_SAFE_KEYS}. */ +function redactMcpServers(servers: unknown): unknown { + if (!Array.isArray(servers)) return servers; + + return servers.map((server) => redactOutside(server, MCP_SERVER_SAFE_KEYS)); +} + +/** Request-transport keys that are credentials by construction. */ +const TRANSPORT_SECRET_KEYS: ReadonlySet = new Set([ + "headers", + "query", + "extra_headers", + "extra_body", + "extra_query", +]); + +type ExtraRunTreeConfig = Pick< + Partial, + "name" | "metadata" | "tags" +>; + +// Type definitions for Anthropic SDK +type MessagesNamespace = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parse?: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: (...args: any[]) => any; +}; + +type ManagedAgentsEventsNamespace = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + send: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + list?: (...args: any[]) => any; +}; + +type AnthropicType = { + messages: MessagesNamespace; + beta?: { + messages?: MessagesNamespace; + sessions?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + retrieve?: (...args: any[]) => any; + events?: ManagedAgentsEventsNamespace; + }; + }; +}; + +type PatchedAnthropicClient = T & { + messages: T["messages"] & { + create: { + ( + arg: Anthropic.MessageCreateParamsStreaming, + arg2?: Anthropic.RequestOptions & { + langsmithExtra?: ExtraRunTreeConfig; + }, + ): Promise>; + } & { + ( + arg: Anthropic.MessageCreateParamsNonStreaming, + arg2?: Anthropic.RequestOptions & { + langsmithExtra?: ExtraRunTreeConfig; + }, + ): Promise; + }; + stream: { + ( + arg: Anthropic.MessageStreamParams, + arg2?: Anthropic.RequestOptions & { + langsmithExtra?: ExtraRunTreeConfig; + }, + ): MessageStream; + }; + }; +}; + +/** + * Create usage metadata from Anthropic's token usage format. + */ +export function createUsageMetadata( + anthropicUsage: Partial | undefined, +): KVMap | undefined { + if (!anthropicUsage) { + return undefined; + } + + const inputTokens = + typeof anthropicUsage.input_tokens === "number" + ? anthropicUsage.input_tokens + : 0; + const outputTokens = + typeof anthropicUsage.output_tokens === "number" + ? anthropicUsage.output_tokens + : 0; + + const inputTokenDetails: Record = + convertAnthropicUsageToInputTokenDetails( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + anthropicUsage as Record, + ); + + // Anthropic cache tokens are ADDITIVE (not subsets of input_tokens like OpenAI). + // Sum them into input_tokens so the backend cost calculation is correct. + const cacheTokenSum = Object.values(inputTokenDetails).reduce( + (sum, v) => sum + (v ?? 0), + 0, + ); + const adjustedInputTokens = inputTokens + cacheTokenSum; + const adjustedTotalTokens = adjustedInputTokens + outputTokens; + + return { + input_tokens: adjustedInputTokens, + output_tokens: outputTokens, + total_tokens: adjustedTotalTokens, + ...(Object.keys(inputTokenDetails).length > 0 && { + input_token_details: inputTokenDetails, + }), + }; +} + +/** + * Process Anthropic message outputs + */ +function processMessageOutput(outputs: KVMap): KVMap { + const message = outputs as Anthropic.Message; + + const result: KVMap = { ...message }; + delete result.type; + + if (message.usage) { + result.usage_metadata = createUsageMetadata(message.usage); + delete result.usage; + } + + return result; +} + +/** + * Accumulate a single content block delta into the content array. + */ +function accumulateContentBlockDelta( + content: Anthropic.ContentBlock[], + event: Anthropic.ContentBlockDeltaEvent, +): void { + const block = content[event.index]; + if (!block) return; + + if (block.type === "text" && event.delta.type === "text_delta") { + (block as Anthropic.TextBlock).text += event.delta.text; + } else if ( + block.type === "tool_use" && + event.delta.type === "input_json_delta" + ) { + // Accumulate JSON input for tool use + const toolBlock = block as Anthropic.ToolUseBlock & { + _partial_json?: string; + }; + toolBlock._partial_json = + (toolBlock._partial_json ?? "") + event.delta.partial_json; + } +} + +/** + * Aggregate streaming chunks into a complete message response + */ +const messageAggregator = (chunks: Anthropic.MessageStreamEvent[]): KVMap => { + if (!chunks || chunks.length === 0) { + return { + role: "assistant", + content: [], + }; + } + + let message: Partial = { + role: "assistant", + content: [], + model: "", + stop_reason: null, + stop_sequence: null, + }; + + // Track usage + let usage: Partial = { + input_tokens: 0, + output_tokens: 0, + }; + + for (const chunk of chunks) { + switch (chunk.type) { + case "message_start": + // Initialize message + message = { + id: chunk.message.id, + role: chunk.message.role, + content: [], + model: chunk.message.model, + stop_reason: chunk.message.stop_reason, + stop_sequence: chunk.message.stop_sequence, + }; + // Capture initial usage + if (chunk.message.usage) { + usage = chunk.message.usage; + } + break; + + case "content_block_start": + // Add new content block + if (message.content) { + (message.content as Anthropic.ContentBlock[])[chunk.index] = + chunk.content_block as Anthropic.ContentBlock; + } + break; + + case "content_block_delta": + // Accumulate delta + if (message.content) { + accumulateContentBlockDelta( + message.content as Anthropic.ContentBlock[], + chunk, + ); + } + break; + + case "content_block_stop": + // Finalize content block + if (message.content) { + const block = (message.content as Anthropic.ContentBlock[])[ + chunk.index + ]; + if (block?.type === "tool_use") { + const toolBlock = block as Anthropic.ToolUseBlock & { + _partial_json?: string; + }; + if (toolBlock._partial_json) { + try { + toolBlock.input = JSON.parse(toolBlock._partial_json); + } catch { + // Keep partial JSON as-is if parsing fails + toolBlock.input = toolBlock._partial_json; + } + delete toolBlock._partial_json; + } + } + } + break; + + case "message_delta": + // Update message metadata + message.stop_reason = chunk.delta.stop_reason; + message.stop_sequence = chunk.delta.stop_sequence ?? null; + if (chunk.usage) { + // Override only non-null keys + for (const [key, value] of Object.entries(chunk.usage)) { + if (value != null) { + (usage as Record)[key] = value; + } + } + } + break; + + case "message_stop": + // Message complete + break; + } + } + + // Build final output + const result: KVMap = { + ...message, + }; + delete result.type; + + // Add usage metadata + result.usage_metadata = createUsageMetadata(usage); + + return result; +}; + +/** + * Wraps an Anthropic client's completion methods, enabling automatic LangSmith + * tracing. Method signatures are unchanged, with the exception that you can pass + * an additional and optional "langsmithExtra" field within the second parameter. + * + * @param anthropic An Anthropic client instance. + * @param options LangSmith options. + * @returns The wrapped client. + * + * @example + * ```ts + * import Anthropic from "@anthropic-ai/sdk"; + * import { wrapAnthropic } from "langsmith/wrappers/anthropic"; + * + * const anthropic = wrapAnthropic(new Anthropic()); + * + * // Non-streaming + * const message = await anthropic.messages.create({ + * model: "claude-sonnet-4-20250514", + * max_tokens: 1024, + * messages: [{ role: "user", content: "Hello!" }], + * }); + * + * // Streaming + * const messageStream = anthropic.messages.stream({ + * model: "claude-sonnet-4-20250514", + * max_tokens: 1024, + * messages: [{ role: "user", content: "Hello!" }], + * }); + * const finalMessage = await messageStream.finalMessage(); + * ``` + */ +export const wrapAnthropic = ( + anthropic: T, + options?: Partial, +): PatchedAnthropicClient => { + if ( + isTraceableFunction(anthropic.messages.create) || + isTraceableFunction(anthropic.messages.stream) || + (anthropic.beta?.sessions?.events?.stream && + isTraceableFunction(anthropic.beta.sessions.events.stream)) || + (anthropic.beta?.sessions?.events?.send && + isTraceableFunction(anthropic.beta.sessions.events.send)) + ) { + throw new Error( + "This instance of Anthropic client has been already wrapped once.", + ); + } + + const tracedAnthropicClient = { ...anthropic }; + + // Extract ls_invocation_params from metadata + const prepopulatedInvocationParams = + typeof options?.metadata?.ls_invocation_params === "object" + ? options.metadata.ls_invocation_params + : {}; + + // Remove ls_invocation_params from metadata to avoid duplication + const { ls_invocation_params, ...restMetadata } = options?.metadata ?? {}; + const cleanedOptions = { + ...options, + metadata: restMetadata, + }; + + /** Mask credentials and surface `system` as a message the playground can edit. */ + function redactAnthropicParams( + params: Record, + ): Record { + // Copy first: `params` also goes to the API and must keep its real values. + const processed = { ...params }; + + if (processed.mcp_servers != null) { + processed.mcp_servers = redactMcpServers(processed.mcp_servers); + } + + for (const key of TRANSPORT_SECRET_KEYS) { + if (processed[key] != null) { + processed[key] = mask(processed[key]); + } + } + + if (!params.system) { + return processed; + } + + // Handle both string and ContentBlock[] formats + const systemContent = Array.isArray(params.system) + ? params.system + .map((block: string | { text: string; type?: string }) => + typeof block === "string" ? block : block.text, + ) + .join("\n") + : params.system; + + // Transform into first message + processed.messages = [ + { role: "system" as const, content: systemContent }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...((params as any).messages || []), + ]; + + delete processed.system; + + return processed; + } + + function processTracedInputs(inputs: KVMap): KVMap { + return overParams(inputs, redactAnthropicParams); + } + + // Common configuration for messages.create + const messagesCreateConfig: TraceableConfig< + typeof anthropic.messages.create + > = { + name: "ChatAnthropic", + run_type: "llm", + aggregator: messageAggregator, + argsConfigPath: [1, "langsmithExtra"], + processInputs: processTracedInputs, + getInvocationParams: (payload: unknown) => { + if (typeof payload !== "object" || payload == null) return undefined; + const params = payload as Anthropic.MessageCreateParams; + + const ls_stop = + (typeof params.stop_sequences === "string" + ? [params.stop_sequences] + : params.stop_sequences) ?? undefined; + + const ls_invocation_params: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (TRACED_INVOCATION_KEYS.includes(key)) { + ls_invocation_params[key] = value; + } + } + + return { + ls_provider: "anthropic", + ls_model_type: "chat", + ls_model_name: params.model, + ls_max_tokens: params.max_tokens ?? undefined, + ls_temperature: params.temperature ?? undefined, + ls_stop, + ls_invocation_params: { + ...prepopulatedInvocationParams, + ...ls_invocation_params, + }, + }; + }, + processOutputs: processMessageOutput, + ...cleanedOptions, + }; + + // Create a new messages object preserving the prototype + tracedAnthropicClient.messages = Object.create( + Object.getPrototypeOf(anthropic.messages), + ); + + // Copy all own properties + Object.assign(tracedAnthropicClient.messages, anthropic.messages); + + // Wrap messages.create + tracedAnthropicClient.messages.create = traceable( + anthropic.messages.create.bind(anthropic.messages), + messagesCreateConfig, + ); + + // Shared function to wrap stream methods + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapStreamMethod = (originalStreamFn: (...args: any[]) => any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (...args: any[]) { + const stream = originalStreamFn(...args); + if ( + "finalMessage" in stream && + typeof stream.finalMessage === "function" + ) { + const originalFinalMessage = stream.finalMessage.bind(stream); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream.finalMessage = async (...args: any[]) => { + if ("done" in stream && typeof stream.done === "function") { + await stream.done(); + } + for await (const _ of stream) { + // Finish consuming the stream if it has not already been consumed + // It should be relatively uncommon to consume an iterator after calling + // .finalMessage() + } + return originalFinalMessage(...args); + }; + } + return stream; + }; + }; + + // Wrap messages.stream + tracedAnthropicClient.messages.stream = traceable( + wrapStreamMethod(anthropic.messages.stream.bind(anthropic.messages)), + { + name: "ChatAnthropic", + run_type: "llm", + aggregator: messageAggregator, + argsConfigPath: [1, "langsmithExtra"], + processInputs: processTracedInputs, + getInvocationParams: messagesCreateConfig.getInvocationParams, + processOutputs: processMessageOutput, + ...cleanedOptions, + }, + ); + + if (anthropic.beta) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tracedBeta = { ...anthropic.beta } as any; + + // Wrap beta.messages if it exists + if ( + anthropic.beta.messages && + typeof anthropic.beta.messages.create === "function" + ) { + tracedBeta.messages = Object.create( + Object.getPrototypeOf(anthropic.beta.messages), + ); + Object.assign(tracedBeta.messages, anthropic.beta.messages); + + // Wrap beta.messages.create + tracedBeta.messages.create = traceable( + anthropic.beta.messages.create.bind(anthropic.beta.messages), + messagesCreateConfig, + ); + + // Wrap beta.messages.parse if it exists + if (typeof anthropic.beta.messages.parse === "function") { + tracedBeta.messages.parse = traceable( + anthropic.beta.messages.parse.bind(anthropic.beta.messages), + messagesCreateConfig, + ); + } + + // Wrap beta.messages.stream if it exists + if (typeof anthropic.beta.messages.stream === "function") { + tracedBeta.messages.stream = traceable( + wrapStreamMethod( + anthropic.beta.messages.stream.bind(anthropic.beta.messages), + ), + { + name: "ChatAnthropic", + run_type: "llm", + aggregator: messageAggregator, + argsConfigPath: [1, "langsmithExtra"], + processInputs: processTracedInputs, + getInvocationParams: messagesCreateConfig.getInvocationParams, + processOutputs: processMessageOutput, + ...cleanedOptions, + }, + ); + } + } + + // Wrap Claude Managed Agents session events if they exist + wrapManagedAgentSessionEvents({ + originalBeta: anthropic.beta, + tracedBeta, + cleanedOptions, + prepopulatedInvocationParams, + }); + + tracedAnthropicClient.beta = tracedBeta; + } + + return tracedAnthropicClient as PatchedAnthropicClient; +}; diff --git a/src/lib/wrappers/gemini.ts b/src/lib/wrappers/gemini.ts new file mode 100644 index 0000000..dc8baa5 --- /dev/null +++ b/src/lib/wrappers/gemini.ts @@ -0,0 +1,517 @@ +import type { RunTreeConfig } from "../index.js"; +import { + isTraceableFunction, + traceable, + type TraceableConfig, +} from "../traceable.js"; +import { KVMap, InvocationParamsSchema } from "../schemas.js"; +import type { + Content, + GenerateContentParameters, + GenerateContentResponse, + GenerateContentResponseUsageMetadata, + SafetyRating, +} from "@google/genai"; +import { createGeminiUsageMetadata } from "./gemini.utils.js"; + +type GoogleGenAIType = { + models: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateContent: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateContentStream: (...args: any[]) => any; + }; +}; + +type PatchedGeminiClient = T & { + models: T["models"] & { + generateContent: T["models"]["generateContent"]; + generateContentStream: T["models"]["generateContentStream"]; + }; +}; + +const chatAggregator = (input: unknown): KVMap => { + const chunks = + Array.isArray(input) && + input.every((item) => typeof item === "object" && item !== null) + ? (input as GenerateContentResponse[]) + : []; + + if (!chunks || chunks.length === 0) { + return { content: "", role: "assistant" }; + } + + let text = ""; + let thoughtText = ""; + const toolCalls: Array> = []; + const otherParts: Array> = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = null; + let finishReason: string | null = null; + let safetyRatings: SafetyRating[] | null = null; + + for (const chunk of chunks) { + if (chunk?.usageMetadata) { + usageMetadata = chunk.usageMetadata; + } + + if (chunk?.candidates && Array.isArray(chunk.candidates)) { + for (const candidate of chunk.candidates) { + if (candidate.finishReason) { + finishReason = candidate.finishReason; + } + if (candidate.safetyRatings) { + safetyRatings = candidate.safetyRatings; + } + + if (candidate.content?.parts) { + for (const part of candidate.content.parts) { + if ("text" in part && part.text !== undefined) { + if (part.thought) { + thoughtText += part.text; + } else { + text += part.text; + } + } else if ("functionCall" in part && part.functionCall) { + toolCalls.push({ + type: "function", + function: { + name: part.functionCall.name || "", + arguments: JSON.stringify(part.functionCall.args || {}), + }, + }); + } else if ( + "codeExecutionResult" in part && + part.codeExecutionResult + ) { + otherParts.push({ + type: "code_execution_result", + code_execution_result: part.codeExecutionResult, + }); + } else if ("executableCode" in part && part.executableCode) { + otherParts.push({ + type: "executable_code", + executable_code: part.executableCode, + }); + } else if ("inlineData" in part && part.inlineData) { + const mimeType = part.inlineData.mimeType || "image/jpeg"; + const data = part.inlineData.data || ""; + otherParts.push({ + type: "image_url", + image_url: { + url: `data:${mimeType};base64,${data}`, + detail: "high", + }, + }); + } else if ("fileData" in part && part.fileData) { + otherParts.push({ + type: "file_data", + mime_type: part.fileData.mimeType, + file_uri: part.fileData.fileUri, + }); + } + } + } + } + } else if (chunk?.text) { + text += chunk.text; + } + } + + const contentParts: Array> = []; + + if (thoughtText) { + contentParts.push({ type: "text", text: thoughtText, thought: true }); + } + + if (text) { + contentParts.push({ type: "text", text: text }); + } + + contentParts.push(...otherParts); + + const result: KVMap = { + role: "assistant", + }; + + if ( + contentParts.length > 1 || + (contentParts.length > 0 && contentParts[0].type !== "text") + ) { + result.content = contentParts; + } else if ( + contentParts.length === 1 && + contentParts[0].type === "text" && + !contentParts[0].thought + ) { + result.content = contentParts[0].text as string; + } else if (thoughtText && !text) { + result.content = contentParts; + } else { + result.content = text || ""; + } + + if (toolCalls.length > 0) { + result.tool_calls = toolCalls; + } + + if (finishReason) { + result.finish_reason = finishReason; + } + + if (safetyRatings) { + result.safety_ratings = safetyRatings; + } + + if (usageMetadata) { + result.usage_metadata = createGeminiUsageMetadata(usageMetadata); + } + + return result; +}; + +function processGeminiInputs(inputs: KVMap): KVMap { + const { contents, ...rest } = inputs as GenerateContentParameters; + if (!contents) return inputs; + + if (typeof contents === "string") { + return { + messages: [{ role: "user", content: contents }], + ...rest, + }; + } + + if (Array.isArray(contents)) { + if (contents.every((item) => typeof item === "string")) { + return { + messages: contents.map((text) => ({ role: "user", content: text })), + ...rest, + }; + } + + const messages = contents + .map((content) => { + if (typeof content !== "object" || content === null) return null; + + const isContent = (item: typeof content): item is Content => { + return "parts" in item && Array.isArray(item.parts); + }; + + const role = "role" in content ? content.role : "user"; + const parts = isContent(content) ? content.parts ?? [] : [content]; + + const textParts: string[] = []; + const contentParts: Array> = []; + + for (const part of parts) { + if (typeof part === "object" && part !== null) { + if ("text" in part && part.text) { + textParts.push(part.text); + contentParts.push({ type: "text", text: part.text }); + } else if ("inlineData" in part) { + const inlineData = part.inlineData; + const mimeType = inlineData?.mimeType || "image/jpeg"; + const data = inlineData?.data || ""; + + contentParts.push({ + type: "image_url", + image_url: { + url: `data:${mimeType};base64,${data}`, + detail: "high", + }, + }); + } else if ("functionResponse" in part) { + const funcResponse = part.functionResponse; + contentParts.push({ + type: "function_response", //TODO: add testing for function_response + function_response: { + name: funcResponse?.name, + response: funcResponse?.response || {}, + }, + }); + } + } else if (typeof part === "string") { + textParts.push(part); + contentParts.push({ type: "text", text: part }); + } + } + + const messageContent = + contentParts.length > 0 && + contentParts.every((p) => p.type === "text") + ? textParts.join("\n") + : contentParts.length > 0 + ? contentParts + : ""; + + return { role, content: messageContent }; + }) + .filter( + ( + msg, + ): msg is { + role: string; + content: string | Record[]; + } => msg !== null, + ); + + return { messages, ...rest }; + } + + return inputs; +} + +function processGeminiOutputs(outputs: Record): KVMap { + const response = (outputs?.outputs || outputs) as + | GenerateContentResponse + | undefined; + + if (!response) { + return { content: "", role: "assistant" }; + } + + if ( + "content" in response && + "role" in response && + !("candidates" in response) + ) { + return response; + } + + let text = ""; + let thoughtText = ""; + const toolCalls: Array> = []; + const otherParts: Array> = []; + let finishReason: string | null = null; + let safetyRatings: SafetyRating[] | null = null; + + if ( + "candidates" in response && + Array.isArray(response.candidates) && + response.candidates.length > 0 + ) { + const firstCandidate = response.candidates[0]; + + if (firstCandidate.finishReason) { + finishReason = firstCandidate.finishReason; + } + if (firstCandidate.safetyRatings) { + safetyRatings = firstCandidate.safetyRatings; + } + + if (firstCandidate?.content?.parts) { + for (const part of firstCandidate.content.parts) { + if ("text" in part && part.text !== undefined) { + if (part.thought) { + thoughtText += part.text; + } else { + text += part.text; + } + } else if ("functionCall" in part && part.functionCall) { + toolCalls.push({ + type: "function", + function: { + name: part.functionCall.name || "", + arguments: JSON.stringify(part.functionCall.args || {}), + }, + }); + } else if ("codeExecutionResult" in part && part.codeExecutionResult) { + otherParts.push({ + type: "code_execution_result", + code_execution_result: part.codeExecutionResult, + }); + } else if ("executableCode" in part && part.executableCode) { + otherParts.push({ + type: "executable_code", + executable_code: part.executableCode, + }); + } else if ("inlineData" in part && part.inlineData) { + const mimeType = part.inlineData.mimeType || "image/jpeg"; + const data = part.inlineData.data || ""; + otherParts.push({ + type: "image_url", + image_url: { + url: `data:${mimeType};base64,${data}`, + detail: "high", + }, + }); + } else if ("fileData" in part && part.fileData) { + otherParts.push({ + type: "file_data", + mime_type: part.fileData.mimeType, + file_uri: part.fileData.fileUri, + }); + } + } + } + } + + const contentParts: Array> = []; + + if (thoughtText) { + contentParts.push({ type: "text", text: thoughtText, thought: true }); + } + if (text) { + contentParts.push({ type: "text", text: text }); + } + contentParts.push(...otherParts); + + const result: KVMap = { + role: "assistant", + }; + + if ( + contentParts.length > 1 || + (contentParts.length > 0 && contentParts[0].type !== "text") + ) { + result.content = contentParts; + } else if ( + contentParts.length === 1 && + contentParts[0].type === "text" && + !contentParts[0].thought + ) { + result.content = contentParts[0].text as string; + } else if (thoughtText && !text) { + result.content = contentParts; + } else { + result.content = text || ""; + } + + if (toolCalls.length > 0) { + result.tool_calls = toolCalls; + } + + if (finishReason) { + result.finish_reason = finishReason; + } + + if (safetyRatings) { + result.safety_ratings = safetyRatings; + } + + if ("usageMetadata" in response && response.usageMetadata) { + result.usage_metadata = createGeminiUsageMetadata(response.usageMetadata); + } + + return result; +} + +function _getGeminiInvocationParams( + prepopulatedInvocationParams: Record, + payload: Record, +): InvocationParamsSchema { + const config = (payload?.[0] || payload) as + | GenerateContentParameters + | undefined; + + return { + ls_provider: "google", + ls_model_type: "chat" as const, + ls_model_name: config?.model || "unknown", + ls_temperature: config?.config?.temperature, + ls_max_tokens: config?.config?.maxOutputTokens, + ls_invocation_params: prepopulatedInvocationParams, + }; +} + +/** + * Wraps a Google Gemini client to enable automatic LangSmith tracing. + * + * **⚠️ BETA: This feature is in beta and may change in future releases.** + * + * Supports tracing for: + * - Text generation (streaming and non-streaming) + * - Multimodal inputs (text + images) + * - Image generation output (gemini-2.5-flash-image) + * - Function calling + * - Usage metadata extraction + * + * @param gemini - A Google GenAI client instance + * @param options - LangSmith tracing configuration options + * @returns A wrapped client with automatic tracing enabled + * + * @example + * ```ts + * import { GoogleGenAI } from "@google/genai"; + * import { wrapGemini } from "langsmith/wrappers/gemini"; + * + * const client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY }); + * const wrapped = wrapGemini(client, { tracingEnabled: true }); + * + * // Use the wrapped client exactly like the original + * const response = await wrapped.models.generateContent({ + * model: "gemini-2.0-flash-exp", + * contents: "Hello!", + * }); + * ``` + */ +export function wrapGemini( + gemini: T, + options?: Partial, +): PatchedGeminiClient { + // Prevent double wrapping + if ( + isTraceableFunction(gemini.models.generateContent) || + isTraceableFunction(gemini.models.generateContentStream) + ) { + throw new Error( + "This Google Gen AI client has already been wrapped. " + + "Wrapping a client multiple times is not supported.", + ); + } + + const tracedGeminiClient = { ...gemini } as PatchedGeminiClient; + + // Extract ls_invocation_params from metadata + const prepopulatedInvocationParams = + typeof options?.metadata?.ls_invocation_params === "object" + ? options.metadata.ls_invocation_params + : {}; + + // Remove ls_invocation_params from metadata to avoid duplication + const { ls_invocation_params, ...restMetadata } = options?.metadata ?? {}; + const cleanedOptions = { + ...options, + metadata: restMetadata, + }; + + const geminiTraceConfig: TraceableConfig< + typeof gemini.models.generateContent + > = { + name: "ChatGoogleGenerativeAI", + run_type: "llm", + getInvocationParams: (payload: Record) => + _getGeminiInvocationParams(prepopulatedInvocationParams ?? {}, payload), + processInputs: processGeminiInputs, + processOutputs: processGeminiOutputs, + ...cleanedOptions, + }; + + const geminiStreamTraceConfig: TraceableConfig< + typeof gemini.models.generateContentStream + > = { + name: "ChatGoogleGenerativeAI", + run_type: "llm", + aggregator: chatAggregator, + getInvocationParams: (payload: Record) => + _getGeminiInvocationParams(prepopulatedInvocationParams ?? {}, payload), + processInputs: processGeminiInputs, + processOutputs: processGeminiOutputs, + ...cleanedOptions, + }; + + tracedGeminiClient.models = { + ...gemini.models, + generateContent: traceable( + gemini.models.generateContent.bind(gemini.models), + geminiTraceConfig, + ), + generateContentStream: traceable( + gemini.models.generateContentStream.bind(gemini.models), + geminiStreamTraceConfig, + ), + }; + + return tracedGeminiClient; +} + +export default wrapGemini; diff --git a/src/lib/wrappers/gemini.utils.ts b/src/lib/wrappers/gemini.utils.ts new file mode 100644 index 0000000..a2235f1 --- /dev/null +++ b/src/lib/wrappers/gemini.utils.ts @@ -0,0 +1,73 @@ +import type { + GenerateContentResponseUsageMetadata, + UsageMetadata, +} from "@google/genai"; + +import { KVMap } from "../schemas.js"; + +interface LangSmithUsageMetadata { + input_tokens: number; + output_tokens: number; + total_tokens: number; + input_token_details?: { + cache_read?: number; + cache_read_over_200k?: number; + over_200k?: number; + }; + output_token_details?: { + reasoning?: number; + over_200k?: number; + }; +} + +export const createGeminiUsageMetadata = ( + usage: UsageMetadata | GenerateContentResponseUsageMetadata, +): KVMap => { + const usageMetadata: LangSmithUsageMetadata = { + input_tokens: usage.promptTokenCount || 0, + output_tokens: (() => { + if ("responseTokenCount" in usage) { + return usage.responseTokenCount || 0; + } + + if ("candidatesTokenCount" in usage) { + return usage.candidatesTokenCount || 0; + } + + return 0; + })(), + total_tokens: usage.totalTokenCount || 0, + }; + + // Add input token details if available + usageMetadata.input_token_details = { + cache_read: + usage.cachedContentTokenCount != null + ? Math.min(usage.cachedContentTokenCount, 200_000) + : undefined, + cache_read_over_200k: + usage.cachedContentTokenCount != null + ? Math.max(0, usage.cachedContentTokenCount - 200_000) + : undefined, + over_200k: + usage.promptTokenCount != null + ? Math.max( + 0, + usage.promptTokenCount - + (usage.cachedContentTokenCount || 0) - + 200_000, + ) + : undefined, + }; + + // Add output token details if available + usageMetadata.output_token_details = { + over_200k: + "candidatesTokenCount" in usage && usage.candidatesTokenCount != null + ? Math.max(0, usage.candidatesTokenCount - 200_000) + : undefined, + reasoning: usage.thoughtsTokenCount ?? undefined, + }; + + return usageMetadata; +}; diff --git a/src/lib/wrappers/generic.ts b/src/lib/wrappers/generic.ts new file mode 100644 index 0000000..2695f4f --- /dev/null +++ b/src/lib/wrappers/generic.ts @@ -0,0 +1,71 @@ +import type { RunTreeConfig } from "../index.js"; +import { traceable } from "../traceable.js"; + +export const _wrapClient = ( + sdk: T, + runName: string, + options?: Omit, +): T => { + return new Proxy(sdk, { + get(target, propKey, receiver) { + const originalValue = target[propKey as keyof T]; + if (typeof originalValue === "function") { + return traceable(originalValue.bind(target), { + run_type: "llm", + ...options, + name: [runName, propKey.toString()].join("."), + }); + } else if ( + originalValue != null && + !Array.isArray(originalValue) && + !(originalValue instanceof Date) && + typeof originalValue === "object" + ) { + return _wrapClient( + originalValue, + [runName, propKey.toString()].join("."), + options, + ); + } else { + return Reflect.get(target, propKey, receiver); + } + }, + }); +}; + +type WrapSDKOptions = Partial< + RunTreeConfig & { + /** + * @deprecated Use `name` instead. + */ + runName: string; + } +>; + +/** + * Wrap an arbitrary SDK, enabling automatic LangSmith tracing. + * Method signatures are unchanged. + * + * Note that this will wrap and trace ALL SDK methods, not just + * LLM completion methods. If the passed SDK contains other methods, + * we recommend using the wrapped instance for LLM calls only. + * @param sdk An arbitrary SDK instance. + * @param options LangSmith options. + * @returns + */ +export const wrapSDK = ( + sdk: T, + options?: WrapSDKOptions, +): T => { + const traceableOptions = options ? { ...options } : undefined; + if (traceableOptions != null) { + delete traceableOptions.runName; + delete traceableOptions.name; + } + + return _wrapClient( + sdk, + options?.name ?? options?.runName ?? sdk.constructor?.name, + traceableOptions, + ); +}; diff --git a/src/lib/wrappers/index.ts b/src/lib/wrappers/index.ts new file mode 100644 index 0000000..6ff1385 --- /dev/null +++ b/src/lib/wrappers/index.ts @@ -0,0 +1,2 @@ +export * from "./openai.js"; +export { wrapSDK } from "./generic.js"; diff --git a/src/lib/wrappers/openai.ts b/src/lib/wrappers/openai.ts new file mode 100644 index 0000000..b182966 --- /dev/null +++ b/src/lib/wrappers/openai.ts @@ -0,0 +1,691 @@ +import type { OpenAI } from "openai"; +import type { APIPromise } from "openai"; +import type { RunTreeConfig } from "../index.js"; +import { + isTraceableFunction, + traceable, + TraceableConfig, +} from "../traceable.js"; +import { InvocationParamsSchema, KVMap } from "../schemas.js"; +import { getCurrentRunTree } from "../singletons/traceable.js"; +import { defaultLsAgentTypeMetadata } from "../utils/ls_agent_type.js"; + +// Extra leniency around types in case multiple OpenAI SDK versions get installed +type OpenAIType = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + beta?: any; + chat: { + completions: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parse?: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream?: (...args: any[]) => any; + }; + }; + completions: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: (...args: any[]) => any; + }; + responses?: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + create: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + retrieve: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + parse: (...args: any[]) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream: (...args: any[]) => any; + }; +}; + +type ExtraRunTreeConfig = Pick< + Partial, + "name" | "metadata" | "tags" +>; + +type PatchedOpenAIClient = T & { + chat: T["chat"] & { + completions: T["chat"]["completions"] & { + create: { + ( + arg: OpenAI.ChatCompletionCreateParamsStreaming, + arg2?: OpenAI.RequestOptions & { + langsmithExtra?: ExtraRunTreeConfig; + }, + ): APIPromise>; + } & { + ( + arg: OpenAI.ChatCompletionCreateParamsNonStreaming, + arg2?: OpenAI.RequestOptions & { + langsmithExtra?: ExtraRunTreeConfig; + }, + ): APIPromise; + }; + }; + }; + completions: T["completions"] & { + create: { + ( + arg: OpenAI.CompletionCreateParamsStreaming, + arg2?: OpenAI.RequestOptions & { langsmithExtra?: ExtraRunTreeConfig }, + ): APIPromise>; + } & { + ( + arg: OpenAI.CompletionCreateParamsNonStreaming, + arg2?: OpenAI.RequestOptions & { langsmithExtra?: ExtraRunTreeConfig }, + ): APIPromise; + }; + }; +}; + +const TRACED_INVOCATION_KEYS = [ + "frequency_penalty", + "n", + "logit_bias", + "logprobs", + "modalities", + "parallel_tool_calls", + "prediction", + "presence_penalty", + "prompt_cache_key", + "reasoning", + "reasoning_effort", + "response_format", + "seed", + "service_tier", + "stream_options", + "top_logprobs", + "top_p", + "truncation", + "user", + "verbosity", + "web_search_options", +]; + +function _combineChatCompletionChoices( + choices: OpenAI.ChatCompletionChunk.Choice[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): any { + const reversedChoices = choices.slice().reverse(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const message: { [key: string]: any } = { + role: "assistant", + content: "", + }; + for (const c of reversedChoices) { + if (c.delta.role) { + message["role"] = c.delta.role; + break; + } + } + const toolCalls: { + [ + key: number + ]: Partial[]; + } = {}; + for (const c of choices) { + if (c.delta.content) { + message.content = message.content.concat(c.delta.content); + } + if (c.delta.function_call) { + if (!message.function_call) { + message.function_call = { name: "", arguments: "" }; + } + if (c.delta.function_call.name) { + message.function_call.name += c.delta.function_call.name; + } + if (c.delta.function_call.arguments) { + message.function_call.arguments += c.delta.function_call.arguments; + } + } + if (c.delta.tool_calls) { + for (const tool_call of c.delta.tool_calls) { + if (!toolCalls[c.index]) { + toolCalls[c.index] = []; + } + toolCalls[c.index].push(tool_call); + } + } + } + if (Object.keys(toolCalls).length > 0) { + message.tool_calls = [...Array(Object.keys(toolCalls).length)]; + for (const [index, toolCallChunks] of Object.entries(toolCalls)) { + const idx = parseInt(index); + message.tool_calls[idx] = { + index: idx, + id: toolCallChunks.find((c) => c.id)?.id || null, + type: toolCallChunks.find((c) => c.type)?.type || null, + }; + for (const chunk of toolCallChunks) { + if (chunk.function) { + if (!message.tool_calls[idx].function) { + message.tool_calls[idx].function = { + name: "", + arguments: "", + }; + } + if (chunk.function.name) { + message.tool_calls[idx].function.name += chunk.function.name; + } + if (chunk.function.arguments) { + message.tool_calls[idx].function.arguments += + chunk.function.arguments; + } + } + } + } + } + return { + index: choices[0].index, + finish_reason: (reversedChoices.find((c) => c.finish_reason) || null) + ?.finish_reason, + message: message, + }; +} + +const chatAggregator = (chunks: OpenAI.ChatCompletionChunk[]) => { + if (!chunks || chunks.length === 0) { + return { choices: [{ message: { role: "assistant", content: "" } }] }; + } + const choicesByIndex: { + [index: number]: OpenAI.ChatCompletionChunk.Choice[]; + } = {}; + for (const chunk of chunks) { + for (const choice of chunk.choices) { + if (choicesByIndex[choice.index] === undefined) { + choicesByIndex[choice.index] = []; + } + choicesByIndex[choice.index].push(choice); + } + } + + const aggregatedOutput = chunks[chunks.length - 1]; + aggregatedOutput.choices = Object.values(choicesByIndex).map((choices) => + _combineChatCompletionChoices(choices), + ); + + return aggregatedOutput; +}; + +const responsesAggregator = (events: any[]) => { + if (!events || events.length === 0) { + return {}; + } + + // Find the response.completed event which contains the final response + for (const event of events) { + if (event.type === "response.completed" && event.response) { + return event.response; + } + } + + // If no completed event found, return the last event + return events[events.length - 1] || {}; +}; + +const textAggregator = ( + allChunks: OpenAI.Completions.Completion[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Record => { + if (allChunks.length === 0) { + return { choices: [{ text: "" }] }; + } + const allContent: string[] = []; + for (const chunk of allChunks) { + const content = chunk.choices[0].text; + if (content != null) { + allContent.push(content); + } + } + const content = allContent.join(""); + const aggregatedOutput = allChunks[allChunks.length - 1]; + aggregatedOutput.choices = [ + { ...aggregatedOutput.choices[0], text: content }, + ]; + return aggregatedOutput; +}; + +function isChatCompletionUsage( + usage: OpenAI.ChatCompletion["usage"] | OpenAI.Responses.Response["usage"], +): usage is OpenAI.ChatCompletion["usage"] { + return usage != null && typeof usage === "object" && "prompt_tokens" in usage; +} + +function processChatCompletion(outputs: KVMap): KVMap { + const openAICompletion = outputs as + | OpenAI.ChatCompletion + | OpenAI.Responses.Response; + const recognizedServiceTier = ["priority", "flex"].includes( + openAICompletion.service_tier ?? "", + ) + ? openAICompletion.service_tier + : undefined; + const serviceTierPrefix = recognizedServiceTier + ? `${recognizedServiceTier}_` + : ""; + // copy the original object, minus usage + const result = { ...openAICompletion } as KVMap; + const usage = openAICompletion.usage; + if (usage) { + let inputTokens = 0; + let outputTokens = 0; + let totalTokens = 0; + let inputTokenDetails: Record = {}; + let outputTokenDetails: Record = {}; + if (isChatCompletionUsage(usage)) { + inputTokens = usage.prompt_tokens ?? 0; + outputTokens = usage.completion_tokens ?? 0; + totalTokens = usage.total_tokens ?? 0; + inputTokenDetails = { + ...(usage.prompt_tokens_details?.audio_tokens !== null && { + audio: usage.prompt_tokens_details?.audio_tokens, + }), + ...(usage.prompt_tokens_details?.cached_tokens !== null && { + [`${serviceTierPrefix}cache_read`]: + usage.prompt_tokens_details?.cached_tokens, + }), + }; + outputTokenDetails = { + ...(usage.completion_tokens_details?.audio_tokens !== null && { + audio: usage.completion_tokens_details?.audio_tokens, + }), + ...(usage.completion_tokens_details?.reasoning_tokens !== null && { + [`${serviceTierPrefix}reasoning`]: + usage.completion_tokens_details?.reasoning_tokens, + }), + }; + } else { + inputTokens = usage.input_tokens ?? 0; + outputTokens = usage.output_tokens ?? 0; + totalTokens = usage.total_tokens ?? 0; + inputTokenDetails = { + ...(usage.input_tokens_details?.cached_tokens !== null && { + [`${serviceTierPrefix}cache_read`]: + usage.input_tokens_details?.cached_tokens, + }), + }; + outputTokenDetails = { + ...(usage.output_tokens_details?.reasoning_tokens !== null && { + [`${serviceTierPrefix}reasoning`]: + usage.output_tokens_details?.reasoning_tokens, + }), + }; + } + if (recognizedServiceTier) { + // Avoid counting cache read and reasoning tokens towards the + // service tier token count since service tier tokens are already + // priced differently + inputTokenDetails[recognizedServiceTier] = + inputTokens - + (inputTokenDetails[`${serviceTierPrefix}cache_read`] ?? 0); + outputTokenDetails[recognizedServiceTier] = + outputTokens - + (outputTokenDetails[`${serviceTierPrefix}reasoning`] ?? 0); + } + result.usage_metadata = { + input_tokens: inputTokens ?? 0, + output_tokens: outputTokens ?? 0, + total_tokens: totalTokens ?? 0, + ...(Object.keys(inputTokenDetails).length > 0 && { + input_token_details: inputTokenDetails, + }), + ...(Object.keys(outputTokenDetails).length > 0 && { + output_token_details: outputTokenDetails, + }), + }; + } + delete result.usage; + return result; +} + +const getChatModelInvocationParamsFn = ( + provider: string, + prepopulatedInvocationParams: Record, + useResponsesApi: boolean, +) => { + return (payload: unknown) => { + if (typeof payload !== "object" || payload == null) return undefined; + const params = payload as Record; + + const ls_stop = + (typeof params.stop === "string" ? [params.stop] : params.stop) ?? + undefined; + + const ls_invocation_params: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (TRACED_INVOCATION_KEYS.includes(key)) { + ls_invocation_params[key] = value; + } + } + + if (useResponsesApi) { + ls_invocation_params.use_responses_api = true; + } + + const requestMetadata = + typeof params.metadata === "object" && + params.metadata !== null && + !Array.isArray(params.metadata) + ? (params.metadata as Record) + : {}; + + return { + ...requestMetadata, + ls_provider: provider, + ls_model_type: "chat", + ls_model_name: params.model, + ls_max_tokens: + params.max_completion_tokens ?? params.max_tokens ?? undefined, + ls_temperature: params.temperature ?? undefined, + ls_stop, + ls_invocation_params: { + ...prepopulatedInvocationParams, + ...ls_invocation_params, + }, + ...defaultLsAgentTypeMetadata(requestMetadata, getCurrentRunTree(true)), + } as InvocationParamsSchema; + }; +}; + +/** + * Wraps an OpenAI client's completion methods, enabling automatic LangSmith + * tracing. Method signatures are unchanged, with the exception that you can pass + * an additional and optional "langsmithExtra" field within the second parameter. + * @param openai An OpenAI client instance. + * @param options LangSmith options. + * @example + * ```ts + * import { OpenAI } from "openai"; + * import { wrapOpenAI } from "langsmith/wrappers/openai"; + * + * const patchedClient = wrapOpenAI(new OpenAI()); + * + * const patchedStream = await patchedClient.chat.completions.create( + * { + * messages: [{ role: "user", content: `Say 'foo'` }], + * model: "gpt-4.1-mini", + * stream: true, + * }, + * { + * langsmithExtra: { + * metadata: { + * additional_data: "bar", + * }, + * }, + * }, + * ); + * ``` + */ +export const wrapOpenAI = ( + openai: T, + options?: Partial, +): PatchedOpenAIClient => { + if ( + isTraceableFunction(openai.chat.completions.create) || + isTraceableFunction(openai.completions.create) + ) { + throw new Error( + "This instance of OpenAI client has been already wrapped once.", + ); + } + + // Attempt to determine if this is an Azure OpenAI client + const isAzureOpenAI = openai.constructor?.name === "AzureOpenAI"; + + const provider = isAzureOpenAI ? "azure" : "openai"; + const chatName = isAzureOpenAI ? "AzureChatOpenAI" : "ChatOpenAI"; + const completionsName = isAzureOpenAI ? "AzureOpenAI" : "OpenAI"; + + // Some internal OpenAI methods call each other, so we need to preserve original + // OpenAI methods. + const tracedOpenAIClient = { ...openai }; + + const prepopulatedInvocationParams = + typeof options?.metadata?.ls_invocation_params === "object" + ? options.metadata.ls_invocation_params + : {}; + + // Remove ls_invocation_params from metadata to avoid duplication + const { ls_invocation_params, ...restMetadata } = options?.metadata ?? {}; + const cleanedOptions = { + ...options, + metadata: restMetadata, + }; + + const chatCompletionParseMetadata: TraceableConfig< + typeof openai.chat.completions.create + > = { + name: chatName, + run_type: "llm", + aggregator: chatAggregator, + argsConfigPath: [1, "langsmithExtra"], + getInvocationParams: getChatModelInvocationParamsFn( + provider, + prepopulatedInvocationParams, + false, + ), + processOutputs: processChatCompletion, + ...cleanedOptions, + }; + + if (openai.beta) { + tracedOpenAIClient.beta = openai.beta; + + if ( + openai.beta.chat && + openai.beta.chat.completions && + typeof openai.beta.chat.completions.parse === "function" + ) { + tracedOpenAIClient.beta.chat.completions.parse = traceable( + openai.beta.chat.completions.parse.bind(openai.beta.chat.completions), + chatCompletionParseMetadata, + ); + } + } + + // Shared function to wrap stream methods (similar to wrapAnthropic) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const wrapStreamMethod = (originalStreamFn: (...args: any[]) => any) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return function (...args: any[]) { + const stream = originalStreamFn(...args); + + // Helper to ensure stream is fully consumed before calling final methods + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ensureStreamConsumed = (methodName: string) => { + if (methodName in stream && typeof stream[methodName] === "function") { + const originalMethod = stream[methodName].bind(stream); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + stream[methodName] = async (...args: any[]) => { + if ("done" in stream && typeof stream.done === "function") { + await stream.done(); + } + for await (const _ of stream) { + // Finish consuming the stream if it has not already been consumed + } + return originalMethod(...args); + }; + } + }; + + // Ensure stream is consumed for final methods + ensureStreamConsumed("finalChatCompletion"); + ensureStreamConsumed("finalMessage"); + ensureStreamConsumed("finalResponse"); + + return stream; + }; + }; + + tracedOpenAIClient.chat = { + ...openai.chat, + completions: Object.create(Object.getPrototypeOf(openai.chat.completions)), + }; + + // Copy all own properties and then wrap specific methods + Object.assign(tracedOpenAIClient.chat.completions, openai.chat.completions); + + // Wrap chat.completions.create + tracedOpenAIClient.chat.completions.create = traceable( + openai.chat.completions.create.bind(openai.chat.completions), + chatCompletionParseMetadata, + ); + + // Wrap chat.completions.parse if it exists + if (typeof openai.chat.completions.parse === "function") { + tracedOpenAIClient.chat.completions.parse = traceable( + openai.chat.completions.parse.bind(openai.chat.completions), + chatCompletionParseMetadata, + ); + } + + // Wrap chat.completions.stream if it exists + if (typeof openai.chat.completions.stream === "function") { + tracedOpenAIClient.chat.completions.stream = traceable( + wrapStreamMethod( + openai.chat.completions.stream.bind(openai.chat.completions), + ), + chatCompletionParseMetadata, + ); + } + + // Wrap beta.chat.completions.stream if it exists + if ( + openai.beta && + openai.beta.chat && + openai.beta.chat.completions && + typeof openai.beta.chat.completions.stream === "function" + ) { + tracedOpenAIClient.beta.chat.completions.stream = traceable( + wrapStreamMethod( + openai.beta.chat.completions.stream.bind(openai.beta.chat.completions), + ), + chatCompletionParseMetadata, + ); + } + + tracedOpenAIClient.completions = { + ...openai.completions, + create: traceable(openai.completions.create.bind(openai.completions), { + name: completionsName, + run_type: "llm", + aggregator: textAggregator, + argsConfigPath: [1, "langsmithExtra"], + getInvocationParams: (payload: unknown) => { + if (typeof payload !== "object" || payload == null) return undefined; + // we can safely do so, as the types are not exported in TSC + const params = payload as OpenAI.CompletionCreateParams; + + const ls_stop = + (typeof params.stop === "string" ? [params.stop] : params.stop) ?? + undefined; + + const ls_invocation_params: Record = {}; + for (const [key, value] of Object.entries(params)) { + if (TRACED_INVOCATION_KEYS.includes(key)) { + ls_invocation_params[key] = value; + } + } + + return { + ls_provider: provider, + ls_model_type: "llm", + ls_model_name: params.model, + ls_max_tokens: params.max_tokens ?? undefined, + ls_temperature: params.temperature ?? undefined, + ls_stop, + ls_invocation_params: { + ...prepopulatedInvocationParams, + ...ls_invocation_params, + }, + ...defaultLsAgentTypeMetadata({}, getCurrentRunTree(true)), + }; + }, + ...cleanedOptions, + }), + }; + + // Add responses API support if it exists + if (openai.responses) { + // Create a new object with the same prototype to preserve all methods + tracedOpenAIClient.responses = Object.create( + Object.getPrototypeOf(openai.responses), + ); + + // Copy all own properties + if (tracedOpenAIClient.responses) { + Object.assign(tracedOpenAIClient.responses, openai.responses); + } + + // Wrap responses.create method + if ( + tracedOpenAIClient.responses && + typeof tracedOpenAIClient.responses.create === "function" + ) { + tracedOpenAIClient.responses.create = traceable( + openai.responses.create.bind(openai.responses), + { + name: chatName, + run_type: "llm", + aggregator: responsesAggregator, + argsConfigPath: [1, "langsmithExtra"], + getInvocationParams: getChatModelInvocationParamsFn( + provider, + prepopulatedInvocationParams, + true, + ), + processOutputs: processChatCompletion, + ...cleanedOptions, + }, + ); + } + + if ( + tracedOpenAIClient.responses && + typeof tracedOpenAIClient.responses.parse === "function" + ) { + tracedOpenAIClient.responses.parse = traceable( + openai.responses.parse.bind(openai.responses), + { + name: chatName, + run_type: "llm", + aggregator: responsesAggregator, + argsConfigPath: [1, "langsmithExtra"], + getInvocationParams: getChatModelInvocationParamsFn( + provider, + prepopulatedInvocationParams, + true, + ), + processOutputs: processChatCompletion, + ...cleanedOptions, + }, + ); + } + + if ( + tracedOpenAIClient.responses && + typeof tracedOpenAIClient.responses.stream === "function" + ) { + tracedOpenAIClient.responses.stream = traceable( + wrapStreamMethod(openai.responses.stream.bind(openai.responses)), + { + name: chatName, + run_type: "llm", + aggregator: responsesAggregator, + argsConfigPath: [1, "langsmithExtra"], + getInvocationParams: getChatModelInvocationParamsFn( + provider, + prepopulatedInvocationParams, + true, + ), + processOutputs: processChatCompletion, + ...cleanedOptions, + }, + ); + } + } + + return tracedOpenAIClient as PatchedOpenAIClient; +}; diff --git a/src/lib/wrappers/openai_agents.ts b/src/lib/wrappers/openai_agents.ts new file mode 100644 index 0000000..6d1bb4b --- /dev/null +++ b/src/lib/wrappers/openai_agents.ts @@ -0,0 +1,976 @@ +/** + * LangSmith integration for OpenAI Agents SDK. + * + * This module provides tracing support for the OpenAI Agents SDK. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import { RunTree } from "../run_trees.js"; +import type { ExtractedUsageMetadata } from "../schemas.js"; +import { Client } from "../client.js"; +import { + AsyncLocalStorageProviderSingleton, + getCurrentRunTree, +} from "../singletons/traceable.js"; +import { + NON_ROOT_LS_AGENT_TYPES, + defaultLsAgentTypeMetadata, +} from "../utils/ls_agent_type.js"; +import type { ContextPlaceholder } from "../singletons/types.js"; +import type { + AgentSpanData, + CustomSpanData, + FunctionSpanData, + GenerationSpanData, + GenerationUsageData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, + Span as SDKSpan, + SpanData, + Trace as SDKTrace, +} from "@openai/agents"; + +AsyncLocalStorageProviderSingleton.initializeGlobalInstance( + new AsyncLocalStorage(), +); + +/** + * Set the current AsyncLocalStorage store to the given RunTree without a + * callback. Uses `AsyncLocalStorage.enterWith` if available on the underlying + * instance (it is on Node's built-in ALS). This is required because the + * OpenAI Agents tracing processor receives `onSpanStart`/`onSpanEnd` callbacks + * at different points with no single function to wrap via `withRunTree`. + * + * Returns the previous store so callers can restore it on exit. + * + * Caveats of `enterWith` (inherent, not avoidable with this API shape): + * - Replaces the ALS store for the current async task and all its + * descendants. Concurrent async tasks spawned from the caller's scope + * during the trace will see the installed store. + * - `onTraceEnd`/`onSpanEnd` restoration only works when it runs on the + * same async task as the matching start. This is guaranteed by the + * OpenAI Agents SDK's span lifecycle (span.start / fn / span.end are + * invoked on one task via `_withSpanFactory`). + */ +function enterRunTreeContext( + runTree: RunTree | undefined, +): RunTree | undefined { + const storage = AsyncLocalStorageProviderSingleton.getInstance(); + const previous = storage.getStore() as RunTree | undefined; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const maybeEnterWith = (storage as any).enterWith; + if (typeof maybeEnterWith === "function") { + maybeEnterWith.call(storage, runTree); + } + return previous; +} + +// `Span` and `Trace` from the SDK are classes with `#private` fields, which +// TypeScript treats as nominally typed — plain mock objects cannot satisfy +// them. We narrow to the subset of public members actually read by the +// processor via `Pick`, which also strips the private brand and lets test +// mocks be plain objects. +type Span = Pick< + SDKSpan, + | "traceId" + | "spanData" + | "spanId" + | "parentId" + | "error" + | "startedAt" + | "endedAt" + | "toJSON" +>; +type Trace = Pick< + SDKTrace, + "traceId" | "name" | "groupId" | "metadata" | "toJSON" +>; + +interface TracingProcessor { + start?(): void; + onTraceStart(trace: Trace): Promise; + onTraceEnd(trace: Trace): Promise; + onSpanStart(span: Span): Promise; + onSpanEnd(span: Span): Promise; + shutdown(timeout?: number): Promise; + forceFlush(): Promise; +} + +type RunTypeT = + | "tool" + | "chain" + | "llm" + | "retriever" + | "embedding" + | "prompt" + | "parser"; + +/** + * Parse inputs or outputs into a dictionary format. + */ +function isOpenAIAgentsItemArray( + data: unknown, +): data is Array> { + return ( + Array.isArray(data) && + data.length > 0 && + data.every( + (item) => + typeof item === "object" && + item !== null && + "type" in item && + typeof (item as { type?: unknown }).type === "string", + ) + ); +} + +function normalizeResponseInputItemsForReplay( + items: Array>, +): Array> { + return items.map((item) => { + const type = item.type; + + if (type === "message") { + return { + type: "message", + role: item.role, + content: item.content, + }; + } + + if (type === "reasoning") { + return { + type: "reasoning", + ...(item.id ? { id: item.id } : {}), + content: Array.isArray(item.content) ? item.content : [], + }; + } + + if (type === "function_call") { + return { + type: "function_call", + ...(item.id ? { id: item.id } : {}), + call_id: item.callId, + name: item.name, + arguments: item.arguments, + }; + } + + if (type === "function_call_result") { + const output = item.output; + return { + type: "function_call_output", + call_id: item.callId, + output: + typeof output === "object" && output !== null && "text" in output + ? output.text + : output, + }; + } + + return item; + }); +} + +function parseIO( + data: unknown, + defaultKey = "output", +): Record { + if (data === null || data === undefined) { + return {}; + } + + if (Array.isArray(data)) { + if (data.length === 0) { + return {}; + } + // Check if this is a list of output blocks (reasoning, message, etc.) + if (data.length > 0 && typeof data[0] === "object" && data[0] !== null) { + if ("type" in data[0]) { + return { [defaultKey]: data }; + } else if (data.length === 1) { + return data[0] as Record; + } + } + return { [defaultKey]: data }; + } + + if (typeof data === "object") { + return data as Record; + } + + if (typeof data === "string") { + try { + const parsed = JSON.parse(data); + if (typeof parsed === "object" && parsed !== null) { + return parsed as Record; + } + return { [defaultKey]: data }; + } catch { + return { [defaultKey]: data }; + } + } + + return { [defaultKey]: data }; +} + +/** + * Get the LangSmith run type for a span. + */ +function getRunType(span: Span): RunTypeT { + const spanType = span.spanData?.type; + if (spanType === "agent" || spanType === "handoff" || spanType === "custom") { + return "chain"; + } else if (spanType === "function" || spanType === "guardrail") { + return "tool"; + } else if (spanType === "generation" || spanType === "response") { + return "llm"; + } + return "chain"; +} + +/** + * Get the run name for a span. + */ +function getRunName(span: Span): string { + const spanData = span.spanData; + if ("name" in spanData && spanData.name) { + return spanData.name; + } + const spanType = spanData?.type; + if (spanType === "generation") { + return "Generation"; + } else if (spanType === "response") { + return "Response"; + } else if (spanType === "handoff") { + return "Handoff"; + } + return "Span"; +} + +function deriveAgentInputsOutputs(run: RunTree): { + inputs?: Record; + outputs?: Record; +} { + const children = [...run.child_runs]; + const firstChildWithInputs = children.find( + (child) => child.inputs && Object.keys(child.inputs).length > 0, + ); + const lastChildWithOutputs = [...children] + .reverse() + .find((child) => child.outputs && Object.keys(child.outputs).length > 0); + + return { + ...(firstChildWithInputs ? { inputs: firstChildWithInputs.inputs } : {}), + ...(lastChildWithOutputs ? { outputs: lastChildWithOutputs.outputs } : {}), + }; +} + +/** + * Extract span data into a format suitable for LangSmith runs. + */ +function extractSpanData(span: Span): Record { + const spanData = span.spanData; + const data: Record = {}; + + if (spanData.type === "function") { + const functionData = spanData as FunctionSpanData; + data.inputs = parseIO(functionData.input, "input"); + data.outputs = parseIO(functionData.output, "output"); + } else if (spanData.type === "generation") { + const generationData = spanData as GenerationSpanData; + data.inputs = parseIO(generationData.input, "input"); + data.outputs = parseIO(generationData.output, "output"); + data.invocation_params = { + model: generationData.model, + model_config: generationData.model_config, + }; + if (generationData.usage) { + data.metadata = { + usage_metadata: createUsageMetadata(generationData.usage), + }; + } + } else if (spanData.type === "response") { + const responseData = spanData as ResponseSpanData; + if (responseData._input !== undefined) { + data.inputs = { + input: isOpenAIAgentsItemArray(responseData._input) + ? normalizeResponseInputItemsForReplay(responseData._input) + : responseData._input, + instructions: + typeof responseData._response?.instructions === "string" + ? responseData._response.instructions + : "", + }; + } + if (responseData._response) { + const response = responseData._response; + const outputData = (response.output as unknown[]) ?? []; + data.outputs = parseIO(outputData, "output"); + // Extract invocation params + const invocationParams: Record = {}; + const invocationKeys = [ + "max_output_tokens", + "model", + "parallel_tool_calls", + "reasoning", + "temperature", + "text", + "tool_choice", + "tools", + "top_p", + "truncation", + ]; + for (const key of invocationKeys) { + if (key in response) { + invocationParams[key] = response[key as keyof typeof response]; + } + } + data.invocation_params = invocationParams; + + // Extract metadata + const metadata: Record = {}; + const metadataKeys = Object.keys(response).filter( + (k) => + k !== "output" && + k !== "usage" && + k !== "instructions" && + !invocationKeys.includes(k), + ); + for (const key of metadataKeys) { + metadata[key] = response[key as keyof typeof response]; + } + metadata.ls_model_name = invocationParams.model; + metadata.ls_max_tokens = invocationParams.max_output_tokens; + metadata.ls_temperature = invocationParams.temperature; + metadata.ls_model_type = "chat"; + metadata.ls_provider = "openai"; + + if (response.usage) { + metadata.usage_metadata = createResponsesUsageMetadata( + response.usage as Record, + ); + } + data.metadata = metadata; + } + } else if (spanData.type === "agent") { + const agentData = spanData as AgentSpanData; + data.invocation_params = { + tools: agentData.tools, + handoffs: agentData.handoffs, + }; + data.metadata = { + output_type: agentData.output_type, + }; + } else if (spanData.type === "handoff") { + const handoffData = spanData as HandoffSpanData; + data.inputs = { + from_agent: handoffData.from_agent, + }; + data.outputs = { + to_agent: handoffData.to_agent, + }; + } else if (spanData.type === "guardrail") { + const guardrailData = spanData as GuardrailSpanData; + data.metadata = { + triggered: guardrailData.triggered, + }; + } else if (spanData.type === "custom") { + const customData = spanData as CustomSpanData; + data.metadata = customData.data; + } + + return data; +} + +/** + * Create usage metadata from a `generation` span's `GenerationUsageData`. + * + * The Agents SDK's generation-span usage shape is intentionally flexible and + * puts token breakdowns under `usage.details` (e.g. `cached_tokens`, + * `reasoning_tokens`, `audio_tokens`). This is distinct from the OpenAI + * Responses API shape used by `response` spans (see + * {@link createResponsesUsageMetadata}). + */ +function createUsageMetadata( + usage: GenerationUsageData, +): ExtractedUsageMetadata { + const inputTokens = (usage.input_tokens as number) ?? 0; + const outputTokens = (usage.output_tokens as number) ?? 0; + + const result: ExtractedUsageMetadata = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: inputTokens + outputTokens, + }; + + // Handle details if present + if (usage.details) { + const details = usage.details as Record; + const inputTokenDetails: Record = {}; + const outputTokenDetails: Record = {}; + + // Map common detail fields + if (typeof details.cached_tokens === "number") { + inputTokenDetails.cache_read = details.cached_tokens; + } + if (typeof details.reasoning_tokens === "number") { + outputTokenDetails.reasoning = details.reasoning_tokens; + } + if (typeof details.audio_tokens === "number") { + inputTokenDetails.audio = details.audio_tokens; + } + + if (Object.keys(inputTokenDetails).length > 0) { + result.input_token_details = inputTokenDetails; + } + if (Object.keys(outputTokenDetails).length > 0) { + result.output_token_details = outputTokenDetails; + } + } + + return result; +} + +/** + * Create usage metadata from a `response` span's embedded OpenAI Responses API + * usage object. + * + * Shape: + * ``` + * { + * input_tokens, output_tokens, total_tokens, + * input_tokens_details: { cached_tokens }, + * output_tokens_details: { reasoning_tokens }, + * } + * ``` + * + * This is distinct from {@link createUsageMetadata}, which handles the + * Agents SDK `GenerationUsageData` shape (with breakdowns under `details`). + */ +function createResponsesUsageMetadata( + usage: Record, +): ExtractedUsageMetadata { + const inputTokens = (usage.input_tokens as number) ?? 0; + const outputTokens = (usage.output_tokens as number) ?? 0; + const totalTokens = + (usage.total_tokens as number) ?? inputTokens + outputTokens; + + const result: ExtractedUsageMetadata = { + input_tokens: inputTokens, + output_tokens: outputTokens, + total_tokens: totalTokens, + }; + + const inputTokenDetails: Record = {}; + const outputTokenDetails: Record = {}; + + const inputDetails = usage.input_tokens_details as + | Record + | undefined; + if (inputDetails && typeof inputDetails.cached_tokens === "number") { + inputTokenDetails.cache_read = inputDetails.cached_tokens; + } + + const outputDetails = usage.output_tokens_details as + | Record + | undefined; + if (outputDetails && typeof outputDetails.reasoning_tokens === "number") { + outputTokenDetails.reasoning = outputDetails.reasoning_tokens; + } + + if (Object.keys(inputTokenDetails).length > 0) { + result.input_token_details = inputTokenDetails; + } + if (Object.keys(outputTokenDetails).length > 0) { + result.output_token_details = outputTokenDetails; + } + + return result; +} + +/** + * Return an ls_agent_type for the span, or undefined to leave it alone. + * + * If the run already has middleware/subagent/compaction, keep it. Otherwise + * guardrails become middleware and agents under any tool become subagents. + * + * Openai-agents structural counterpart to resolveVercelLsAgentType. + */ +function _resolveOpenAIAgentsLsAgentType( + spanData: SpanData, + parentRun: RunTree, + existingTag: unknown, +): "middleware" | "subagent" | undefined { + if ( + typeof existingTag === "string" && + NON_ROOT_LS_AGENT_TYPES.has(existingTag) + ) { + return undefined; + } + if (spanData.type === "guardrail") return "middleware"; + // Walk the full parent chain, not just the direct parent: asTool inserts + // a chain run between the tool and the inner agent span. + if (spanData.type === "agent") { + let cursor: RunTree | undefined = parentRun; + while (cursor !== undefined) { + if (cursor.run_type === "tool") return "subagent"; + cursor = cursor.parent_run; + } + } + return undefined; +} + +/** + * Tracing processor for the [OpenAI Agents SDK](https://openai.github.io/openai-agents-js/). + * + * Traces all intermediate steps of your OpenAI Agent to LangSmith. + * + * Requirements: Make sure to install `npm install @openai/agents`. + * + * Installing this processor is itself an explicit opt-in to tracing, + * so traces will be posted regardless of the `LANGSMITH_TRACING` env + * variable. Any nested `traceable()` calls made from within an agent + * run (e.g. inside a tool handler) will inherit this and also post, + * even if `LANGSMITH_TRACING` is not set. + * + * @param client - An instance of `langsmith.Client`. If not provided, a default client is created. + * @param metadata - Metadata to associate with all traces. + * @param tags - Tags to associate with all traces. + * @param projectName - LangSmith project to trace to. + * @param name - Name of the root trace. + * + * @example + * ```typescript + * import { Agent, Runner, function_tool, setTraceProcessors } from "@openai/agents"; + * import { OpenAIAgentsTracingProcessor } from "langsmith/wrappers/openai_agents"; + * + * setTraceProcessors([new OpenAIAgentsTracingProcessor()]); + * + * const getWeather = function_tool({ + * name: "get_weather", + * description: "Get the weather for a city", + * parameters: { type: "object", properties: { city: { type: "string" } } }, + * run: async ({ city }: { city: string }) => `The weather in ${city} is sunny`, + * }); + * + * const agent = new Agent({ + * name: "Assistant", + * instructions: "You are a helpful assistant", + * model: "gpt-4.1-mini", + * tools: [getWeather], + * }); + * + * const result = await Runner.run(agent, "What's the weather in New York?"); + * console.log(result.finalOutput); + * ``` + */ + +export class OpenAIAgentsTracingProcessor implements TracingProcessor { + private client: Client; + private _metadata?: Record; + private _tags?: string[]; + private _projectName?: string; + private _name?: string; + + private _firstResponseInputs: Record> = {}; + private _lastResponseOutputs: Record> = {}; + + private _runs: Map = new Map(); + private _unpostedTraces: Set = new Set(); + private _unpostedSpans: Set = new Set(); + // Previous AsyncLocalStorage store for each trace/span, so nested + // traceable() calls inside Agents tools correctly nest under the + // enclosing span and context can be restored when the span/trace ends. + private _previousStoreByTrace: Map = new Map(); + private _previousStoreBySpan: Map = new Map(); + + constructor(options?: { + client?: Client; + metadata?: Record; + tags?: string[]; + projectName?: string; + name?: string; + }) { + this.client = options?.client ?? new Client(); + this._metadata = options?.metadata; + this._tags = options?.tags; + this._projectName = options?.projectName; + this._name = options?.name; + } + + async onTraceStart(trace: Trace): Promise { + let currentRunTree: RunTree | undefined; + try { + currentRunTree = getCurrentRunTree(); + } catch { + // Not in a traceable context + currentRunTree = undefined; + } + + // Determine run name + let runName: string; + if (this._name) { + runName = this._name; + } else if (trace.name) { + runName = trace.name; + } else { + runName = "Agent workflow"; + } + + // Merge three sources: processor defaults, trace.metadata (user's per-run + // ls_agent_type), and force-set ls_integration. + const mergedMetadata: Record = { + ...this._metadata, + ...((trace.metadata as Record | undefined) ?? {}), + ls_integration: "openai-agents-sdk", + }; + Object.assign( + mergedMetadata, + defaultLsAgentTypeMetadata(mergedMetadata, currentRunTree), + ); + const runExtra: Record = { metadata: mergedMetadata }; + + const traceDict = (trace.toJSON() as Record) ?? {}; + const groupId = + trace.groupId ?? + (traceDict.groupId as string | null | undefined) ?? + (traceDict.group_id as string | null | undefined); + if (groupId !== undefined && groupId !== null) { + (runExtra.metadata as Record).thread_id = groupId; + } + + try { + let newRun: RunTree; + if (currentRunTree !== undefined) { + // Nest under existing trace + newRun = currentRunTree.createChild({ + name: runName, + run_type: "chain", + inputs: {}, + extra: runExtra, + tags: this._tags, + }); + } else { + // Create new root trace. Force `tracingEnabled: true` because + // installing this processor is itself an explicit opt-in to + // tracing; this ensures nested `traceable()` calls inside tools + // (which otherwise gate on LANGSMITH_TRACING) also post their + // runs. The setting propagates to children via createChild. + const runTreeConfig: ConstructorParameters[0] = { + name: runName, + run_type: "chain", + inputs: {}, + extra: runExtra, + tags: this._tags, + client: this.client, + tracingEnabled: true, + }; + + if (this._projectName !== undefined) { + runTreeConfig.project_name = this._projectName; + } + + newRun = new RunTree(runTreeConfig); + } + + // Delay posting until first response/generation span ends + // so inputs can be included in the POST. + this._unpostedTraces.add(trace.traceId); + this._runs.set(trace.traceId, newRun); + + // Set this run as the current context so nested traceable() calls + // invoked from inside Agents tools nest under it. Remember the previous + // store so we can restore it in onTraceEnd. + const previousStore = enterRunTreeContext(newRun); + this._previousStoreByTrace.set(trace.traceId, previousStore); + } catch (e) { + console.error("Error creating trace run:", e); + } + } + + async onTraceEnd(trace: Trace): Promise { + const run = this._runs.get(trace.traceId); + if (!run) { + return; + } + + this._runs.delete(trace.traceId); + + const traceDict = (trace.toJSON() as Record) ?? {}; + // trace.metadata may have new keys since onTraceStart; pull them in. + const metadata: Record = { + ...this._metadata, + ...(traceDict.metadata as Record), + }; + + try { + // Update run with final inputs/outputs + run.outputs = this._lastResponseOutputs[trace.traceId] ?? {}; + + // Update metadata + if (!run.extra) { + run.extra = {}; + } + if (!run.extra.metadata) { + run.extra.metadata = {}; + } + run.extra.metadata = { + ...run.extra.metadata, + ...metadata, + }; + + // End and patch + await run.end(); + + if (this._unpostedTraces.has(trace.traceId)) { + // No response/generation spans ended, post now + run.inputs = this._firstResponseInputs[trace.traceId] ?? {}; + this._unpostedTraces.delete(trace.traceId); + await run.postRun(); + } else { + await run.patchRun({ excludeInputs: true }); + } + + delete this._firstResponseInputs[trace.traceId]; + delete this._lastResponseOutputs[trace.traceId]; + } catch (e) { + console.error("Error updating trace run:", e); + } finally { + // Restore the previous AsyncLocalStorage store so contexts outside + // this trace are not polluted. + if (this._previousStoreByTrace.has(trace.traceId)) { + const previousStore = this._previousStoreByTrace.get(trace.traceId); + this._previousStoreByTrace.delete(trace.traceId); + enterRunTreeContext(previousStore); + } + } + } + + async onSpanStart(span: Span): Promise { + // Find parent run + const parentId = span.parentId; + const parentRun = parentId + ? this._runs.get(parentId) + : this._runs.get(span.traceId); + + if (!parentRun) { + console.warn(`No trace info found for span, skipping: ${span.spanId}`); + return; + } + + // Extract span data + let runName = getRunName(span); + const spanData = span.spanData; + + if (spanData.type === "response") { + const parentName = parentRun.name; + const rawSpanName = runName; + if (parentName) { + runName = `${parentName} ${rawSpanName}`.trim(); + } else { + runName = rawSpanName; + } + } + + const runType = getRunType(span); + const extracted = extractSpanData(span); + + // Create child run and install it into AsyncLocalStorage SYNCHRONOUSLY, + // before any `await`. The OpenAI Agents runtime invokes `span.start()` + // (which calls this method without awaiting) right before it executes + // the tool/agent body in the same async task. Setting ALS via + // `enterWith` here ensures nested `traceable()` calls inside tool + // `execute` functions see this span's RunTree as their parent. + let childRun: RunTree; + try { + childRun = parentRun.createChild({ + name: runName, + run_type: runType, + inputs: (extracted.inputs as Record) ?? {}, + extra: extracted, + start_time: span.startedAt + ? new Date(span.startedAt).getTime() + : undefined, + }); + } catch (e) { + console.error("Error creating span run:", e); + return; + } + + // Handoffs replace the caller rather than run as a tool, so a handoff + // agent has no tool ancestor and correctly stays untagged here. + if (!childRun.extra) childRun.extra = {}; + if (!childRun.extra.metadata) childRun.extra.metadata = {}; + const meta = childRun.extra.metadata as Record; + const structuralTag = _resolveOpenAIAgentsLsAgentType( + spanData, + parentRun, + meta.ls_agent_type, + ); + if (structuralTag !== undefined) { + meta.ls_agent_type = structuralTag; + } + + this._runs.set(span.spanId, childRun); + + // Enter AsyncLocalStorage context synchronously so nested traceable() + // calls inside the span's body nest under this run. Remember the + // previous store so we can restore it in onSpanEnd. + const previousStore = enterRunTreeContext(childRun); + this._previousStoreBySpan.set(span.spanId, previousStore); + + try { + // Delay posting for spans whose complete inputs/outputs aren't + // available at start. + if ( + spanData.type === "generation" || + spanData.type === "response" || + spanData.type === "agent" || + spanData.type === "function" || + spanData.type === "handoff" + ) { + this._unpostedSpans.add(span.spanId); + } else { + await childRun.postRun(); + } + } catch (e) { + console.error("Error posting span run:", e); + } + } + + async onSpanEnd(span: Span): Promise { + // Restore the previous AsyncLocalStorage store synchronously so any + // further async work in the enclosing scope doesn't see this span's + // run as its parent. Done before any await to match span.end() + // which fires onSpanEnd without awaiting. + if (this._previousStoreBySpan.has(span.spanId)) { + const previousStore = this._previousStoreBySpan.get(span.spanId); + this._previousStoreBySpan.delete(span.spanId); + enterRunTreeContext(previousStore); + } + + const run = this._runs.get(span.spanId); + if (!run) { + return; + } + + this._runs.delete(span.spanId); + + try { + // Extract outputs and metadata + const extracted = extractSpanData(span); + const outputs = (extracted.outputs as Record) ?? {}; + const inputs = (extracted.inputs as Record) ?? {}; + + // Update run + run.outputs = outputs; + if (Object.keys(inputs).length > 0) { + run.inputs = inputs; + } + if (span.error) { + run.error = span.error.message; + } + + if (span.spanData.type === "agent") { + const derived = deriveAgentInputsOutputs(run); + if ( + Object.keys(run.inputs ?? {}).length === 0 && + derived.inputs && + Object.keys(derived.inputs).length > 0 + ) { + run.inputs = derived.inputs; + } + if ( + Object.keys(run.outputs ?? {}).length === 0 && + derived.outputs && + Object.keys(derived.outputs).length > 0 + ) { + run.outputs = derived.outputs; + } + } + + // Add OpenAI metadata + if (!run.extra) { + run.extra = {}; + } + if (!run.extra.metadata) { + run.extra.metadata = {}; + } + run.extra.metadata = { + ...run.extra.metadata, + openai_parent_id: span.parentId ?? undefined, + openai_trace_id: span.traceId, + openai_span_id: span.spanId, + }; + + if (extracted.metadata) { + run.extra.metadata = { + ...run.extra.metadata, + ...(extracted.metadata as Record), + }; + } + if (extracted.invocation_params) { + run.extra.invocation_params = extracted.invocation_params; + } + + const spanData = span.spanData; + if (spanData.type === "response") { + this._firstResponseInputs[span.traceId] = + this._firstResponseInputs[span.traceId] ?? inputs; + this._lastResponseOutputs[span.traceId] = outputs; + await this._maybePostTrace(span.traceId, inputs); + } else if (spanData.type === "generation") { + this._firstResponseInputs[span.traceId] = + this._firstResponseInputs[span.traceId] ?? inputs; + this._lastResponseOutputs[span.traceId] = outputs; + await this._maybePostTrace(span.traceId, inputs); + } + + // End the run + if (span.endedAt) { + await run.end(undefined, undefined, new Date(span.endedAt).getTime()); + } else { + await run.end(); + } + + if (this._unpostedSpans.has(span.spanId)) { + this._unpostedSpans.delete(span.spanId); + await run.postRun(); + } else { + await run.patchRun({ excludeInputs: true }); + } + } catch (e) { + console.error("Error updating span run:", e); + } + } + + private async _maybePostTrace( + traceId: string, + inputs: Record, + ): Promise { + if (this._unpostedTraces.has(traceId)) { + const traceRun = this._runs.get(traceId); + if (traceRun) { + traceRun.inputs = inputs; + try { + await traceRun.postRun(); + } catch (e) { + console.error("Error posting trace:", e); + } + this._unpostedTraces.delete(traceId); + } + } + } + + async shutdown(): Promise { + await this.client.flush(); + await this.client.awaitPendingTraceBatches(); + } + + async forceFlush(): Promise { + await this.client.flush(); + await this.client.awaitPendingTraceBatches(); + } +} diff --git a/src/lib/wrappers/utils/anthropic_managed.ts b/src/lib/wrappers/utils/anthropic_managed.ts new file mode 100644 index 0000000..85d85a5 --- /dev/null +++ b/src/lib/wrappers/utils/anthropic_managed.ts @@ -0,0 +1,898 @@ +import type { + BetaManagedAgentsStreamSessionEvents, + BetaManagedAgentsSession, +} from "@anthropic-ai/sdk/resources/beta/sessions/index.mjs"; +import type { RunTreeConfig } from "../../index.js"; +import { getCurrentRunTree } from "../../traceable.js"; +import { KVMap } from "../../schemas.js"; +import { RunTree } from "../../run_trees.js"; +import { createUsageMetadata } from "../anthropic.js"; +import { isRecord } from "../../utils/types.js"; + +type OnlyType = + BetaManagedAgentsStreamSessionEvents extends infer TEvent + ? TEvent extends { type: TType } + ? TEvent + : never + : never; + +function processManagedAgentStreamInputs(inputs: KVMap): KVMap { + const args = Array.isArray(inputs.args) ? inputs.args : []; + const [sessionID, params] = + args.length > 0 ? args : [inputs.input, undefined]; + return { + session_id: sessionID, + ...(params ? { stream_params: params } : {}), + }; +} + +function getManagedAgentText(content: unknown): string { + if (!Array.isArray(content)) return ""; + return content + .map((block) => { + if ( + typeof block === "object" && + block != null && + "type" in block && + block.type === "text" && + "text" in block && + typeof block.text === "string" + ) { + return block.text; + } + return ""; + }) + .join(""); +} + +function managedAgentSessionEventsAggregator( + chunks: BetaManagedAgentsStreamSessionEvents[], +): KVMap { + const toolCalls: KVMap[] = []; + const toolResults: KVMap[] = []; + const errors: KVMap[] = []; + const modelRequests: KVMap[] = []; + const previews = new Map>(); + + let status: string | undefined; + let stopReason: unknown; + const usage = { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }; + + for (const event of chunks) { + switch (event.type) { + case "agent.message": { + if (typeof event.id === "string") previews.delete(event.id); + break; + } + case "agent.tool_use": + case "agent.mcp_tool_use": + case "agent.custom_tool_use": { + toolCalls.push({ ...event }); + break; + } + case "agent.tool_result": + case "agent.mcp_tool_result": + case "user.custom_tool_result": { + toolResults.push({ ...event }); + break; + } + case "span.model_request_end": { + const modelUsage = event.model_usage; + if (modelUsage) { + for (const key of Object.keys(usage) as Array) { + const value = modelUsage[key]; + if (typeof value === "number") usage[key] += value; + } + modelRequests.push({ + id: event.id, + is_error: event.is_error, + model_request_start_id: event.model_request_start_id, + model_usage: modelUsage, + processed_at: event.processed_at, + }); + } + previews.clear(); + break; + } + case "session.error": + errors.push({ ...event }); + break; + case "session.status_running": + case "session.status_idle": + case "session.status_rescheduled": + case "session.status_terminated": + status = event.type; + if (event.type === "session.status_idle") { + stopReason = event.stop_reason; + } + break; + } + } + + const unreconciledPreviews = [...previews.entries()].map( + ([eventID, byIndex]) => ({ + id: eventID, + text: [...byIndex.entries()] + .sort(([left], [right]) => left - right) + .map(([, text]) => text) + .join(""), + }), + ); + const messageEvents = chunks.filter( + (event) => event.type === "agent.message", + ); + const lastMessage = + getManagedAgentAssistantOutputMessages(messageEvents).at(-1); + return { + content: messageEvents.flatMap((message) => + Array.isArray(message.content) ? message.content : [], + ), + ...(lastMessage ? { messages: [lastMessage] } : {}), + tool_calls: toolCalls, + tool_results: toolResults, + errors, + model_requests: modelRequests, + status, + stop_reason: stopReason, + events: chunks, + ...(unreconciledPreviews.length > 0 + ? { unreconciled_previews: unreconciledPreviews } + : {}), + }; +} + +function getManagedAgentInputEvents( + chunks: BetaManagedAgentsStreamSessionEvents[], +): BetaManagedAgentsStreamSessionEvents[] { + return chunks + .filter( + (event) => + typeof event.type === "string" && + (event.type.startsWith("user.") || event.type.startsWith("system.")), + ) + .map((event) => ({ ...event })); +} + +function contentBlocksToChatContent(content: unknown): unknown { + if (!Array.isArray(content)) return content; + const text = getManagedAgentText(content); + return text.length > 0 && + content.every((block) => { + return ( + typeof block === "object" && + block != null && + "type" in block && + block.type === "text" + ); + }) + ? text + : content; +} + +function managedAgentToolUseToContentBlock( + event: OnlyType< + "agent.tool_use" | "agent.mcp_tool_use" | "agent.custom_tool_use" + >, +): KVMap { + return { + type: "tool_use", + id: event.id, + name: event.name, + input: event.input, + ...(event.type === "agent.mcp_tool_use" + ? { mcp_server_name: event.mcp_server_name } + : {}), + }; +} + +function getManagedAgentChatMessages( + events: BetaManagedAgentsStreamSessionEvents[], +): KVMap[] { + const messages: KVMap[] = []; + for (const event of events) { + switch (event.type) { + case "user.message": + messages.push({ + role: "user", + content: contentBlocksToChatContent(event.content), + }); + break; + case "agent.message": + messages.push({ + role: "assistant", + content: contentBlocksToChatContent(event.content), + }); + break; + case "agent.tool_use": + case "agent.mcp_tool_use": + case "agent.custom_tool_use": + messages.push({ + role: "assistant", + content: [managedAgentToolUseToContentBlock(event)], + }); + break; + case "agent.tool_result": + messages.push({ + role: "tool", + tool_call_id: event.tool_use_id, + content: event.content, + ...(event.is_error != null ? { is_error: event.is_error } : {}), + }); + break; + case "agent.mcp_tool_result": + messages.push({ + role: "tool", + tool_call_id: event.mcp_tool_use_id, + content: event.content, + ...(event.is_error != null ? { is_error: event.is_error } : {}), + }); + break; + case "user.custom_tool_result": + messages.push({ + role: "tool", + tool_call_id: event.custom_tool_use_id, + content: event.content, + ...(event.is_error != null ? { is_error: event.is_error } : {}), + }); + break; + case "user.interrupt": + case "user.define_outcome": + case "user.tool_result": + case "user.tool_confirmation": + break; + } + } + return messages; +} + +function getManagedAgentAssistantOutputMessages( + events: BetaManagedAgentsStreamSessionEvents[], +): KVMap[] { + return events.flatMap((event) => { + if (event.type === "agent.message") { + return [ + { + id: event.id, + role: "assistant", + content: contentBlocksToChatContent(event.content), + processed_at: event.processed_at, + }, + ]; + } + if ( + event.type === "agent.tool_use" || + event.type === "agent.mcp_tool_use" || + event.type === "agent.custom_tool_use" + ) { + return [ + { + id: event.id, + role: "assistant", + content: [managedAgentToolUseToContentBlock(event)], + processed_at: event.processed_at, + }, + ]; + } + return []; + }); +} + +function getManagedAgentStreamError( + chunks: BetaManagedAgentsStreamSessionEvents[], +): string | undefined { + const errorEvent = chunks.find((event) => event.type === "session.error"); + const error = errorEvent?.error; + if (typeof error === "object" && error != null && "message" in error) { + return String(error.message); + } + return undefined; +} + +function isCustomToolRequiresActionIdle( + event: BetaManagedAgentsStreamSessionEvents, + chunks: BetaManagedAgentsStreamSessionEvents[], +): boolean { + if ( + event.type !== "session.status_idle" || + event.stop_reason.type !== "requires_action" + ) { + return false; + } + return event.stop_reason.event_ids.every((eventID) => + chunks.some( + (candidate) => + candidate.type === "agent.custom_tool_use" && candidate.id === eventID, + ), + ); +} + +function getManagedAgentTurnError( + chunks: BetaManagedAgentsStreamSessionEvents[], +): string | undefined { + const idleEvent = [...chunks] + .reverse() + .find((event) => event.type === "session.status_idle"); + if (idleEvent?.type === "session.status_idle") { + if (idleEvent.stop_reason.type === "requires_action") { + return `Interrupted: requires_action (${idleEvent.stop_reason.event_ids.join( + ", ", + )})`; + } + if (idleEvent.stop_reason.type === "retries_exhausted") { + return "Interrupted: retries_exhausted"; + } + } + return undefined; +} + +function stripLangSmithExtraFromRequestOptions(options: T): T { + if ( + typeof options === "object" && + options != null && + "langsmithExtra" in options + ) { + const { langsmithExtra: _langsmithExtra, ...rest } = options; + return rest as T; + } + return options; +} + +function getProcessedAtMillis( + event: BetaManagedAgentsStreamSessionEvents | undefined, +): number | undefined { + if (!event) return undefined; + return typeof event.processed_at === "string" + ? Date.parse(event.processed_at) + : undefined; +} + +function getFirstProcessedAtMillis( + events: BetaManagedAgentsStreamSessionEvents[], +): number | undefined { + for (const event of events) { + const processedAt = getProcessedAtMillis(event); + if (processedAt !== undefined) return processedAt; + } + return undefined; +} + +function getLastProcessedAtMillis( + events: BetaManagedAgentsStreamSessionEvents[], +): number | undefined { + for (const event of [...events].reverse()) { + const processedAt = getProcessedAtMillis(event); + if (processedAt !== undefined) return processedAt; + } + return undefined; +} + +async function createManagedAgentChildRuns( + parentRun: RunTree, + chunks: { + turn: BetaManagedAgentsStreamSessionEvents[]; + all: BetaManagedAgentsStreamSessionEvents[]; + }, + metadata: KVMap, + options: { + modelConfig: KVMap | undefined; + systemPrompt: string | undefined; + pendingToolUseEvents: Map< + string, + OnlyType< + "agent.tool_use" | "agent.mcp_tool_use" | "agent.custom_tool_use" + > + >; + completedChildRunIds: Set; + }, +): Promise { + const { + modelConfig, + systemPrompt, + pendingToolUseEvents, + completedChildRunIds, + } = options; + + const modelName = + typeof modelConfig?.id === "string" ? modelConfig.id : undefined; + const modelRequestStarts = new Map< + string, + OnlyType<"span.model_request_start"> + >(); + const childSpecs: Array<{ + startTime: number; + index: number; + createAndPost: () => Promise; + }> = []; + + const toolUseEvents = new Map< + string, + OnlyType<"agent.tool_use" | "agent.mcp_tool_use" | "agent.custom_tool_use"> + >(); + const toolResultEvents = new Map< + string, + OnlyType< + "agent.tool_result" | "agent.mcp_tool_result" | "user.custom_tool_result" + > + >(); + + // Aggregate tool use and tool results from all events + // This is necessary because tool results may arrive after the model request ends (eg custom tools) + for (const event of chunks.all) { + if ( + (event.type === "agent.tool_use" || + event.type === "agent.mcp_tool_use" || + event.type === "agent.custom_tool_use") && + typeof event.id === "string" + ) { + toolUseEvents.set(event.id, event); + } else if ( + event.type === "agent.tool_result" && + typeof event.tool_use_id === "string" + ) { + toolResultEvents.set(event.tool_use_id, event); + } else if ( + event.type === "agent.mcp_tool_result" && + typeof event.mcp_tool_use_id === "string" + ) { + toolResultEvents.set(event.mcp_tool_use_id, event); + } else if ( + event.type === "user.custom_tool_result" && + typeof event.custom_tool_use_id === "string" + ) { + toolResultEvents.set(event.custom_tool_use_id, event); + } + } + + // Handle turn specific events (model requests, tool calls) + for (const event of chunks.turn) { + if ( + event.type === "span.model_request_start" && + typeof event.id === "string" + ) { + modelRequestStarts.set(event.id, event); + } else if (event.type === "span.model_request_end") { + const startID = event.model_request_start_id; + const startEvent = modelRequestStarts.get(startID); + + const startIndex = startEvent ? chunks.all.indexOf(startEvent) : -1; + const endIndex = chunks.all.indexOf(event); + + const eventsBeforeRequest = + startIndex >= 0 ? chunks.all.slice(0, startIndex) : []; + + const eventsInRequest = + startIndex >= 0 && endIndex >= startIndex + ? chunks.all.slice(startIndex, endIndex + 1) + : [event]; + + const messageEvents = eventsInRequest.filter( + (candidate) => candidate.type === "agent.message", + ); + const messages = getManagedAgentAssistantOutputMessages(eventsInRequest); + const toolCalls = eventsInRequest.filter( + (candidate) => + candidate.type === "agent.tool_use" || + candidate.type === "agent.mcp_tool_use" || + candidate.type === "agent.custom_tool_use", + ); + const content = messageEvents.flatMap((message) => + Array.isArray(message.content) ? message.content : [], + ); + + const usageMetadata = createUsageMetadata(event.model_usage); + const startTime = startEvent + ? getProcessedAtMillis(startEvent) ?? Date.now() + : Date.now(); + + childSpecs.push({ + startTime, + index: startIndex >= 0 ? startIndex : endIndex, + createAndPost: async () => { + const childRun = parentRun.createChild({ + name: "ClaudeManagedAgentModelRequest", + run_type: "llm", + inputs: { + events: eventsBeforeRequest, + system: systemPrompt, + messages: getManagedAgentChatMessages(eventsBeforeRequest), + ...(startEvent ? { model_request_start: startEvent } : {}), + }, + metadata: { + ...metadata, + ...(modelName ? { ls_model_name: modelName } : {}), + ...(modelConfig + ? { + ls_invocation_params: { + ...((metadata.ls_invocation_params as + | KVMap + | undefined) ?? {}), + model_config: modelConfig, + }, + } + : {}), + ...(usageMetadata ? { usage_metadata: usageMetadata } : {}), + }, + start_time: startTime, + }); + + await childRun.end( + { + content, + messages, + tool_calls: toolCalls, + model_request_end: event, + ...(event.model_usage ? { model_usage: event.model_usage } : {}), + ...(usageMetadata ? { usage_metadata: usageMetadata } : {}), + }, + event.is_error ? "Model request failed" : undefined, + getProcessedAtMillis(event), + ); + await childRun.postRun(); + }, + }); + } + } + + for (const [toolUseID, toolUse] of toolUseEvents.entries()) { + const result = toolResultEvents.get(toolUseID); + const isCurrentTurnTool = + chunks.turn.includes(toolUse) || + (result ? chunks.turn.includes(result) : false); + const pendingToolUse = pendingToolUseEvents.get(toolUseID); + + if ( + completedChildRunIds.has(toolUseID) && + pendingToolUse == null && + !isCurrentTurnTool + ) { + continue; + } + + // The model requested a tool but execution is blocked on user confirmation + // or an external custom-tool result. Do not create a tool child yet; the + // execution belongs to the later turn that receives the result. + if (result == null) { + if ( + chunks.turn.includes(toolUse) && + !completedChildRunIds.has(toolUseID) + ) { + pendingToolUseEvents.set(toolUseID, toolUse); + } + continue; + } + + if (!isCurrentTurnTool && pendingToolUse == null) { + continue; + } + + const toolUseForRun = pendingToolUse ?? toolUse; + const toolName = + typeof toolUseForRun.name === "string" + ? toolUseForRun.name + : "ManagedAgentTool"; + const startTime = + pendingToolUse != null + ? getProcessedAtMillis(result) ?? Date.now() + : getProcessedAtMillis(toolUseForRun) ?? Date.now(); + const index = + pendingToolUse != null + ? chunks.all.indexOf(result) + : chunks.all.indexOf(toolUseForRun); + + childSpecs.push({ + startTime, + index, + createAndPost: async () => { + const resultArgs = [ + { event: result, content: result.content }, + result.is_error + ? getManagedAgentText(result.content) ?? "Tool execution failed" + : undefined, + getProcessedAtMillis(result), + ] as [ + outputs: KVMap, + error: string | undefined, + endTime: number | undefined, + ]; + + const childRun = parentRun.createChild({ + name: toolName, + run_type: "tool", + inputs: { + id: toolUseID, + name: toolUseForRun.name, + input: toolUseForRun.input, + event: toolUseForRun, + }, + metadata, + start_time: startTime, + }); + await childRun.end(...resultArgs); + await childRun.postRun(); + pendingToolUseEvents.delete(toolUseID); + completedChildRunIds.add(toolUseID); + }, + }); + } + + childSpecs.sort( + (left, right) => + left.startTime - right.startTime || left.index - right.index, + ); + for (const childSpec of childSpecs) { + await childSpec.createAndPost(); + } +} + +export function wrapManagedAgentSessionEvents({ + originalBeta, + tracedBeta, + cleanedOptions, + prepopulatedInvocationParams, +}: { + originalBeta: any; + tracedBeta: any; + cleanedOptions: Partial; + prepopulatedInvocationParams: unknown; +}): void { + const createManagedAgentStreamRun = ( + sessionID: string, + params?: KVMap, + requestOptions?: { langsmithExtra?: Partial }, + startTime?: number, + ) => { + const runtimeConfig = requestOptions?.langsmithExtra ?? {}; + const parentRun = getCurrentRunTree(true); + const runConfig: RunTreeConfig = { + ...cleanedOptions, + ...runtimeConfig, + name: runtimeConfig.name ?? cleanedOptions.name ?? "ClaudeManagedAgent", + run_type: "chain", + inputs: { session_id: sessionID }, + ...(startTime ? { start_time: startTime } : {}), + tags: [ + ...new Set([ + ...(cleanedOptions.tags ?? []), + ...(runtimeConfig.tags ?? []), + ]), + ], + metadata: { + ...cleanedOptions.metadata, + ...runtimeConfig.metadata, + ls_provider: "anthropic", + ls_model_type: "chat", + thread_id: sessionID, + ls_invocation_params: { + ...(typeof prepopulatedInvocationParams === "object" && + prepopulatedInvocationParams != null + ? prepopulatedInvocationParams + : {}), + session_id: sessionID, + ...(params ?? {}), + }, + }, + }; + return parentRun + ? parentRun.createChild(runConfig) + : new RunTree(runConfig); + }; + + // Wrap Claude Managed Agents session event methods if they exist. + if (originalBeta.sessions?.events) { + tracedBeta.sessions = Object.create( + Object.getPrototypeOf(tracedBeta.sessions), + ); + Object.assign(tracedBeta.sessions, originalBeta.sessions); + + tracedBeta.sessions.events = Object.create( + Object.getPrototypeOf(tracedBeta.sessions.events), + ); + Object.assign(tracedBeta.sessions.events, originalBeta.sessions.events); + + if (typeof originalBeta.sessions.events.stream === "function") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const streamManagedAgentEvents = async function (...args: any[]) { + const [sessionID, params, requestOptions] = args as [ + string, + KVMap | undefined, + ({ langsmithExtra?: Partial } & Record< + string, + unknown + >)?, + ]; + const sanitizedArgs = [...args]; + sanitizedArgs[2] = stripLangSmithExtraFromRequestOptions( + sanitizedArgs[2], + ); + while ( + sanitizedArgs.length > 1 && + sanitizedArgs[sanitizedArgs.length - 1] === undefined + ) { + sanitizedArgs.pop(); + } + + const sessionPromise: Promise = + originalBeta.sessions?.retrieve + ? originalBeta.sessions.retrieve + .bind(originalBeta.sessions)(sessionID) + .catch(() => undefined) + : Promise.resolve(undefined); + + const stream = await originalBeta.sessions?.events?.stream.bind( + originalBeta.sessions.events, + )(...sanitizedArgs); + + const iterator: AsyncIterator = + stream[Symbol.asyncIterator](); + + const allChunks: BetaManagedAgentsStreamSessionEvents[] = []; + const turnChunkLengths: Set = new Set(); + const pendingToolUseEvents: Map< + string, + OnlyType< + "agent.tool_use" | "agent.mcp_tool_use" | "agent.custom_tool_use" + > + > = new Map(); + const completedChildRunIds: Set = new Set(); + + let flushLength = 0; + let observedTerminalEvent = false; + + const finalize = async ( + kind: "done" | "throw" | "flush", + userError?: string, + ) => { + const chunks = allChunks.slice(flushLength); + flushLength = allChunks.length; + if (chunks.length > 0) turnChunkLengths.add(flushLength); + + // Question: should we re-attempt to fetch when finalizing again? + // ie if the agent's system prompt has changed mid-stream + const session = await sessionPromise; + + const modelConfig = isRecord(session?.agent?.model) + ? session.agent.model + : undefined; + + const systemPrompt = session?.agent.system ?? undefined; + + if (chunks.length > 0) { + const turnStartTime = + getFirstProcessedAtMillis(chunks) ?? Date.now(); + const rawTurnEndTime = getLastProcessedAtMillis(chunks); + const turnEndTime = + rawTurnEndTime !== undefined && rawTurnEndTime >= turnStartTime + ? rawTurnEndTime + : turnStartTime; + const runTree = createManagedAgentStreamRun( + sessionID, + params, + requestOptions, + turnStartTime, + ); + const inputEvents = getManagedAgentInputEvents(chunks); + runTree.inputs = { + ...processManagedAgentStreamInputs({ + args: [sessionID, params, sanitizedArgs[2]], + }), + ...(inputEvents.length > 0 + ? { + events: inputEvents, + messages: getManagedAgentChatMessages(inputEvents), + } + : {}), + }; + + const turnError = + userError ?? + getManagedAgentStreamError(chunks) ?? + getManagedAgentTurnError(chunks); + await runTree.end( + managedAgentSessionEventsAggregator(chunks), + turnError, + turnEndTime, + ); + await runTree.postRun(); + + await createManagedAgentChildRuns( + runTree, + { turn: chunks, all: allChunks }, + runTree.extra.metadata ?? {}, + { + modelConfig, + systemPrompt, + pendingToolUseEvents, + completedChildRunIds, + }, + ); + } + + if (kind === "done" || kind === "throw") { + // Pending tool uses without results were never executed, so no tool + // child run was created. Clear them when the stream ends. + pendingToolUseEvents.clear(); + } + }; + + stream[Symbol.asyncIterator] = () => ({ + async next() { + try { + const result = await iterator.next(); + if (result.done) { + await finalize("done"); + return result; + } + allChunks.push(result.value); + if ( + result.value.type === "session.status_idle" || + result.value.type === "session.status_terminated" || + result.value.type === "session.deleted" || + result.value.type === "session.error" + ) { + if (!isCustomToolRequiresActionIdle(result.value, allChunks)) { + observedTerminalEvent = true; + await finalize("flush"); + } + } + return result; + } catch (error) { + await finalize("throw", String(error)); + throw error; + } + }, + async return(value?: unknown) { + if (allChunks.length) { + await iterator.return?.(value); + if (observedTerminalEvent) { + await finalize("done"); + } else { + await finalize("throw", "Cancelled"); + } + } else { + await iterator.return?.(value); + } + return { done: true, value }; + }, + async throw(error?: unknown) { + await finalize("throw", String(error)); + if (iterator.throw) return iterator.throw(error); + throw error; + }, + }); + return stream; + }; + Object.defineProperty(streamManagedAgentEvents, "langsmith:traceable", { + value: { name: "ClaudeManagedAgent", run_type: "chain" }, + }); + tracedBeta.sessions.events.stream = streamManagedAgentEvents; + } + + if (typeof originalBeta.sessions.events.send === "function") { + // Do not trace `send` as a separate run. The API returns the submitted + // user events, which makes the run output look like a duplicate of the + // input. Instead, annotate the active stream run for the same session. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tracedBeta.sessions.events.send = function sendManagedAgentEvents( + ...args: any[] + ) { + const sanitizedArgs = [...args]; + if ( + typeof sanitizedArgs[2] === "object" && + sanitizedArgs[2] != null && + "langsmithExtra" in sanitizedArgs[2] + ) { + const { langsmithExtra: _langsmithExtra, ...rest } = sanitizedArgs[2]; + sanitizedArgs[2] = rest; + } + return originalBeta.sessions?.events?.send.bind( + originalBeta.sessions.events, + )(...sanitizedArgs); + }; + } + } +} diff --git a/src/pagination.ts b/src/pagination.ts new file mode 100644 index 0000000..90bf015 --- /dev/null +++ b/src/pagination.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/pagination instead */ +export * from './core/pagination'; diff --git a/src/resource.ts b/src/resource.ts new file mode 100644 index 0000000..363e351 --- /dev/null +++ b/src/resource.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/resource instead */ +export * from './core/resource'; diff --git a/src/resources.ts b/src/resources.ts new file mode 100644 index 0000000..b283d57 --- /dev/null +++ b/src/resources.ts @@ -0,0 +1 @@ +export * from './resources/index'; diff --git a/src/resources/annotation-queues.ts b/src/resources/annotation-queues.ts new file mode 100644 index 0000000..ba231c5 --- /dev/null +++ b/src/resources/annotation-queues.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './annotation-queues/index'; diff --git a/src/resources/annotation-queues/annotation-queues.ts b/src/resources/annotation-queues/annotation-queues.ts new file mode 100644 index 0000000..5c4f2e2 --- /dev/null +++ b/src/resources/annotation-queues/annotation-queues.ts @@ -0,0 +1,694 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as ItemsAPI from './items'; +import { + ItemCreateParams, + ItemCreateResponse, + ItemCreateStatusParams, + ItemCreateStatusResponse, + ItemDeleteAllParams, + ItemDeleteAllResponse, + ItemListParams, + ItemListResponse, + ItemListResponsesItemsCursorGetPagination, + ItemRetrieveCountParams, + ItemRetrieveCountResponse, + ItemRetrievePlacementParams, + ItemRetrievePlacementResponse, + ItemUpdateParams, + ItemUpdateResponse, + Items, +} from './items'; +import * as RunsAPI from './runs'; +import { + RunCreateByKeyParams, + RunCreateByKeyResponse, + RunCreateParams, + RunCreateResponse, + RunDeleteAllParams, + RunDeleteAllResponse, + RunDeleteQueueParams, + RunDeleteQueueResponse, + RunListParams, + RunListResponse, + RunUpdateParams, + RunUpdateResponse, + Runs, +} from './runs'; +import * as DatasetsAPI from '../datasets/datasets'; +import { APIPromise } from '../../core/api-promise'; +import { + OffsetPaginationTopLevelArray, + type OffsetPaginationTopLevelArrayParams, + PagePromise, +} from '../../core/pagination'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; +import * as RunsAPI_ from '../runs/runs'; + +export class AnnotationQueues extends APIResource { + runs: RunsAPI.Runs = new RunsAPI.Runs(this._client); + items: ItemsAPI.Items = new ItemsAPI.Items(this._client); + + /** + * Get Annotation Queue + */ + retrieve(queueID: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${queueID}`, options); + } + + /** + * Update Annotation Queue + */ + update(queueID: string, body: AnnotationQueueUpdateParams, options?: RequestOptions): APIPromise { + return this._client.patch(path`/api/v1/annotation-queues/${queueID}`, { body, ...options }); + } + + /** + * Delete Annotation Queue + */ + delete(queueID: string, options?: RequestOptions): APIPromise { + return this._client.delete(path`/api/v1/annotation-queues/${queueID}`, options); + } + + /** + * Create Annotation Queue + */ + annotationQueues( + body: AnnotationQueueAnnotationQueuesParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post('/api/v1/annotation-queues', { body, ...options }); + } + + /** + * Create Identity Annotation Queue Run Status + * + * @deprecated Deprecated: use the annotation queue items create_status method (POST /api/v1/platform/annotation-queues/items/{queue_item_id}/status) instead. Will be removed after Jan 31, 2027. + */ + createRunStatus( + annotationQueueRunID: string, + body: AnnotationQueueCreateRunStatusParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v1/annotation-queues/status/${annotationQueueRunID}`, { + body, + ...options, + }); + } + + /** + * Export Annotation Queue Archived Runs + */ + export(queueID: string, body: AnnotationQueueExportParams, options?: RequestOptions): APIPromise { + return this._client.post(path`/api/v1/annotation-queues/${queueID}/export`, { body, ...options }); + } + + /** + * Populate annotation queue with runs from an experiment. + */ + populate(body: AnnotationQueuePopulateParams, options?: RequestOptions): APIPromise { + return this._client.post('/api/v1/annotation-queues/populate', { body, ...options }); + } + + /** + * Get Annotation Queues + */ + retrieveAnnotationQueues( + query: AnnotationQueueRetrieveAnnotationQueuesParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise< + AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, + AnnotationQueueRetrieveAnnotationQueuesResponse + > { + return this._client.getAPIList( + '/api/v1/annotation-queues', + OffsetPaginationTopLevelArray, + { query, ...options }, + ); + } + + /** + * Get Annotation Queues For Run + */ + retrieveQueues(runID: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${runID}/queues`, options); + } + + /** + * Get a run from an annotation queue + * + * @deprecated Deprecated: use the annotation queue items list and retrieve_placement methods instead, which call GET /api/v1/platform/annotation-queues/{queue_id}/items and GET /api/v1/platform/annotation-queues/{queue_id}/items/{item_id}/placement. Will be removed after Jan 31, 2027. + */ + retrieveRun( + index: number, + params: AnnotationQueueRetrieveRunParams, + options?: RequestOptions, + ): APIPromise { + const { queue_id, ...query } = params; + return this._client.get(path`/api/v1/annotation-queues/${queue_id}/run/${index}`, { query, ...options }); + } + + /** + * Get Size From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items retrieve_count method (GET /api/v1/platform/annotation-queues/{queue_id}/items/count) with the desired status instead. Will be removed after Jan 31, 2027. + */ + retrieveSize( + queueID: string, + query: AnnotationQueueRetrieveSizeParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${queueID}/size`, { query, ...options }); + } + + /** + * Get Total Archived From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items retrieve_count method (GET /api/v1/platform/annotation-queues/{queue_id}/items/count?status=archived) instead. Will be removed after Jan 31, 2027. + */ + retrieveTotalArchived( + queueID: string, + query: AnnotationQueueRetrieveTotalArchivedParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${queueID}/total_archived`, { query, ...options }); + } + + /** + * Get Total Size From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items retrieve_count method (GET /api/v1/platform/annotation-queues/{queue_id}/items/count?status=all) instead. Will be removed after Jan 31, 2027. + */ + retrieveTotalSize(queueID: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${queueID}/total_size`, options); + } +} + +export type AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray = + OffsetPaginationTopLevelArray; + +export interface AnnotationQueueRubricItemSchema { + feedback_key: string; + + description?: string | null; + + is_assertion?: boolean | null; + + is_required?: boolean | null; + + regex_validator?: string | DatasetsAPI.Missing | null; + + score_descriptions?: { [key: string]: string } | null; + + value_descriptions?: { [key: string]: string } | null; +} + +/** + * AnnotationQueue schema. + */ +export interface AnnotationQueueSchema { + id: string; + + name: string; + + queue_type: 'single' | 'pairwise'; + + tenant_id: string; + + assigned_reviewers?: Array; + + created_at?: string; + + default_dataset?: string | null; + + description?: string | null; + + enable_reservations?: boolean | null; + + metadata?: { [key: string]: unknown } | null; + + num_reviewers_per_item?: number | null; + + reservation_minutes?: number | null; + + reviewer_access_mode?: string; + + run_rule_id?: string | null; + + source_rule_id?: string | null; + + updated_at?: string; +} + +export namespace AnnotationQueueSchema { + /** + * Identity info for an assigned reviewer on an annotation queue. + */ + export interface AssignedReviewer { + id: string; + + email?: string | null; + + name?: string | null; + } +} + +/** + * Size of an Annotation Queue + */ +export interface AnnotationQueueSizeSchema { + size: number; +} + +/** + * Run schema with annotation queue info. + */ +export interface RunSchemaWithAnnotationQueueInfo { + id: string; + + app_path: string; + + dotted_order: string; + + name: string; + + queue_run_id: string; + + /** + * Enum for run types. + */ + run_type: RunsAPI_.RunTypeEnum; + + session_id: string; + + status: string; + + trace_id: string; + + added_at?: string | null; + + child_run_ids?: Array | null; + + completed_by?: Array; + + completion_cost?: string | null; + + completion_cost_details?: { [key: string]: string } | null; + + completion_token_details?: { [key: string]: number } | null; + + completion_tokens?: number; + + direct_child_run_ids?: Array | null; + + effective_added_at?: string | null; + + end_time?: string | null; + + error?: string | null; + + events?: Array<{ [key: string]: unknown }> | null; + + execution_order?: number; + + extra?: { [key: string]: unknown } | null; + + feedback_stats?: { [key: string]: { [key: string]: unknown } } | null; + + first_token_time?: string | null; + + in_dataset?: boolean | null; + + inputs?: { [key: string]: unknown } | null; + + inputs_preview?: string | null; + + inputs_s3_urls?: { [key: string]: unknown } | null; + + last_queued_at?: string | null; + + last_reviewed_time?: string | null; + + manifest_id?: string | null; + + manifest_s3_id?: string | null; + + messages?: Array<{ [key: string]: unknown }> | null; + + outputs?: { [key: string]: unknown } | null; + + outputs_preview?: string | null; + + outputs_s3_urls?: { [key: string]: unknown } | null; + + parent_run_id?: string | null; + + parent_run_ids?: Array | null; + + price_model_id?: string | null; + + prompt_cost?: string | null; + + prompt_cost_details?: { [key: string]: string } | null; + + prompt_token_details?: { [key: string]: number } | null; + + prompt_tokens?: number; + + reference_dataset_id?: string | null; + + reference_example_id?: string | null; + + reserved_by?: Array; + + s3_urls?: { [key: string]: unknown } | null; + + serialized?: { [key: string]: unknown } | null; + + share_token?: string | null; + + source_proposed_example_id?: string | null; + + start_time?: string; + + tags?: Array | null; + + thread_id?: string | null; + + total_cost?: string | null; + + total_tokens?: number; + + trace_first_received_at?: string | null; + + trace_max_start_time?: string | null; + + trace_min_start_time?: string | null; + + trace_tier?: 'longlived' | 'shortlived' | null; + + trace_upgrade?: boolean; + + ttl_seconds?: number | null; +} + +/** + * AnnotationQueue schema with rubric. + */ +export interface AnnotationQueueRetrieveResponse { + id: string; + + name: string; + + queue_type: 'single' | 'pairwise'; + + tenant_id: string; + + assigned_reviewers?: Array; + + created_at?: string; + + default_dataset?: string | null; + + description?: string | null; + + enable_reservations?: boolean | null; + + metadata?: { [key: string]: unknown } | null; + + num_reviewers_per_item?: number | null; + + reservation_minutes?: number | null; + + reviewer_access_mode?: string; + + rubric_instructions?: string | null; + + rubric_items?: Array | null; + + run_rule_id?: string | null; + + source_rule_id?: string | null; + + updated_at?: string; +} + +export namespace AnnotationQueueRetrieveResponse { + /** + * Identity info for an assigned reviewer on an annotation queue. + */ + export interface AssignedReviewer { + id: string; + + email?: string | null; + + name?: string | null; + } +} + +export type AnnotationQueueUpdateResponse = unknown; + +export type AnnotationQueueDeleteResponse = unknown; + +export type AnnotationQueueCreateRunStatusResponse = unknown; + +export type AnnotationQueueExportResponse = unknown; + +export type AnnotationQueuePopulateResponse = unknown; + +/** + * AnnotationQueue schema with size. + */ +export interface AnnotationQueueRetrieveAnnotationQueuesResponse { + id: string; + + name: string; + + queue_type: 'single' | 'pairwise'; + + tenant_id: string; + + total_runs: number; + + assigned_reviewers?: Array; + + created_at?: string; + + default_dataset?: string | null; + + description?: string | null; + + enable_reservations?: boolean | null; + + metadata?: { [key: string]: unknown } | null; + + num_reviewers_per_item?: number | null; + + reservation_minutes?: number | null; + + reviewer_access_mode?: string; + + run_rule_id?: string | null; + + source_rule_id?: string | null; + + updated_at?: string; +} + +export namespace AnnotationQueueRetrieveAnnotationQueuesResponse { + /** + * Identity info for an assigned reviewer on an annotation queue. + */ + export interface AssignedReviewer { + id: string; + + email?: string | null; + + name?: string | null; + } +} + +export type AnnotationQueueRetrieveQueuesResponse = Array; + +export interface AnnotationQueueUpdateParams { + default_dataset?: string | null; + + description?: string | null; + + enable_reservations?: boolean; + + metadata?: { [key: string]: unknown } | DatasetsAPI.Missing | null; + + name?: string | null; + + num_reviewers_per_item?: number | DatasetsAPI.Missing | null; + + reservation_minutes?: number | null; + + reviewer_access_mode?: 'any' | 'assigned' | null; + + rubric_instructions?: string | null; + + rubric_items?: Array | null; +} + +export interface AnnotationQueueAnnotationQueuesParams { + name: string; + + id?: string; + + created_at?: string; + + default_dataset?: string | null; + + description?: string | null; + + enable_reservations?: boolean | null; + + metadata?: { [key: string]: unknown } | null; + + num_reviewers_per_item?: number | null; + + reservation_minutes?: number | null; + + reviewer_access_mode?: string; + + rubric_instructions?: string | null; + + rubric_items?: Array | null; + + session_ids?: Array | null; + + updated_at?: string; +} + +export interface AnnotationQueueCreateRunStatusParams { + override_added_at?: string | null; + + status?: string | null; +} + +export interface AnnotationQueueExportParams { + end_time?: string | null; + + include_annotator_detail?: boolean; + + start_time?: string | null; +} + +export interface AnnotationQueuePopulateParams { + queue_id: string; + + session_ids: Array; + + extend_trace_retention?: boolean; +} + +export interface AnnotationQueueRetrieveAnnotationQueuesParams extends OffsetPaginationTopLevelArrayParams { + assigned_to_me?: boolean; + + dataset_id?: string | null; + + ids?: Array | null; + + name?: string | null; + + name_contains?: string | null; + + queue_type?: 'single' | 'pairwise' | null; + + sort_by?: string | null; + + sort_by_desc?: boolean; + + tag_value_id?: Array | null; +} + +export interface AnnotationQueueRetrieveRunParams { + /** + * Path param + */ + queue_id: string; + + /** + * Query param + */ + include_extra?: boolean; +} + +export interface AnnotationQueueRetrieveSizeParams { + status?: 'needs_my_review' | 'needs_others_review' | 'completed' | null; +} + +export interface AnnotationQueueRetrieveTotalArchivedParams { + end_time?: string | null; + + start_time?: string | null; +} + +AnnotationQueues.Runs = Runs; +AnnotationQueues.Items = Items; + +export declare namespace AnnotationQueues { + export { + type AnnotationQueueRubricItemSchema as AnnotationQueueRubricItemSchema, + type AnnotationQueueSchema as AnnotationQueueSchema, + type AnnotationQueueSizeSchema as AnnotationQueueSizeSchema, + type RunSchemaWithAnnotationQueueInfo as RunSchemaWithAnnotationQueueInfo, + type AnnotationQueueRetrieveResponse as AnnotationQueueRetrieveResponse, + type AnnotationQueueUpdateResponse as AnnotationQueueUpdateResponse, + type AnnotationQueueDeleteResponse as AnnotationQueueDeleteResponse, + type AnnotationQueueCreateRunStatusResponse as AnnotationQueueCreateRunStatusResponse, + type AnnotationQueueExportResponse as AnnotationQueueExportResponse, + type AnnotationQueuePopulateResponse as AnnotationQueuePopulateResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponse as AnnotationQueueRetrieveAnnotationQueuesResponse, + type AnnotationQueueRetrieveQueuesResponse as AnnotationQueueRetrieveQueuesResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray as AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, + type AnnotationQueueUpdateParams as AnnotationQueueUpdateParams, + type AnnotationQueueAnnotationQueuesParams as AnnotationQueueAnnotationQueuesParams, + type AnnotationQueueCreateRunStatusParams as AnnotationQueueCreateRunStatusParams, + type AnnotationQueueExportParams as AnnotationQueueExportParams, + type AnnotationQueuePopulateParams as AnnotationQueuePopulateParams, + type AnnotationQueueRetrieveAnnotationQueuesParams as AnnotationQueueRetrieveAnnotationQueuesParams, + type AnnotationQueueRetrieveRunParams as AnnotationQueueRetrieveRunParams, + type AnnotationQueueRetrieveSizeParams as AnnotationQueueRetrieveSizeParams, + type AnnotationQueueRetrieveTotalArchivedParams as AnnotationQueueRetrieveTotalArchivedParams, + }; + + export { + Runs as Runs, + type RunCreateResponse as RunCreateResponse, + type RunUpdateResponse as RunUpdateResponse, + type RunListResponse as RunListResponse, + type RunCreateByKeyResponse as RunCreateByKeyResponse, + type RunDeleteAllResponse as RunDeleteAllResponse, + type RunDeleteQueueResponse as RunDeleteQueueResponse, + type RunCreateParams as RunCreateParams, + type RunUpdateParams as RunUpdateParams, + type RunListParams as RunListParams, + type RunCreateByKeyParams as RunCreateByKeyParams, + type RunDeleteAllParams as RunDeleteAllParams, + type RunDeleteQueueParams as RunDeleteQueueParams, + }; + + export { + Items as Items, + type ItemCreateResponse as ItemCreateResponse, + type ItemUpdateResponse as ItemUpdateResponse, + type ItemListResponse as ItemListResponse, + type ItemCreateStatusResponse as ItemCreateStatusResponse, + type ItemDeleteAllResponse as ItemDeleteAllResponse, + type ItemRetrieveCountResponse as ItemRetrieveCountResponse, + type ItemRetrievePlacementResponse as ItemRetrievePlacementResponse, + type ItemListResponsesItemsCursorGetPagination as ItemListResponsesItemsCursorGetPagination, + type ItemCreateParams as ItemCreateParams, + type ItemUpdateParams as ItemUpdateParams, + type ItemListParams as ItemListParams, + type ItemCreateStatusParams as ItemCreateStatusParams, + type ItemDeleteAllParams as ItemDeleteAllParams, + type ItemRetrieveCountParams as ItemRetrieveCountParams, + type ItemRetrievePlacementParams as ItemRetrievePlacementParams, + }; +} diff --git a/src/resources/annotation-queues/index.ts b/src/resources/annotation-queues/index.ts new file mode 100644 index 0000000..1b9c414 --- /dev/null +++ b/src/resources/annotation-queues/index.ts @@ -0,0 +1,60 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + AnnotationQueues, + type AnnotationQueueRubricItemSchema, + type AnnotationQueueSchema, + type AnnotationQueueSizeSchema, + type RunSchemaWithAnnotationQueueInfo, + type AnnotationQueueRetrieveResponse, + type AnnotationQueueUpdateResponse, + type AnnotationQueueDeleteResponse, + type AnnotationQueueCreateRunStatusResponse, + type AnnotationQueueExportResponse, + type AnnotationQueuePopulateResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponse, + type AnnotationQueueRetrieveQueuesResponse, + type AnnotationQueueUpdateParams, + type AnnotationQueueAnnotationQueuesParams, + type AnnotationQueueCreateRunStatusParams, + type AnnotationQueueExportParams, + type AnnotationQueuePopulateParams, + type AnnotationQueueRetrieveAnnotationQueuesParams, + type AnnotationQueueRetrieveRunParams, + type AnnotationQueueRetrieveSizeParams, + type AnnotationQueueRetrieveTotalArchivedParams, + type AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, +} from './annotation-queues'; +export { + Items, + type ItemCreateResponse, + type ItemUpdateResponse, + type ItemListResponse, + type ItemCreateStatusResponse, + type ItemDeleteAllResponse, + type ItemRetrieveCountResponse, + type ItemRetrievePlacementResponse, + type ItemCreateParams, + type ItemUpdateParams, + type ItemListParams, + type ItemCreateStatusParams, + type ItemDeleteAllParams, + type ItemRetrieveCountParams, + type ItemRetrievePlacementParams, + type ItemListResponsesItemsCursorGetPagination, +} from './items'; +export { + Runs, + type RunCreateResponse, + type RunUpdateResponse, + type RunListResponse, + type RunCreateByKeyResponse, + type RunDeleteAllResponse, + type RunDeleteQueueResponse, + type RunCreateParams, + type RunUpdateParams, + type RunListParams, + type RunCreateByKeyParams, + type RunDeleteAllParams, + type RunDeleteQueueParams, +} from './runs'; diff --git a/src/resources/annotation-queues/items.ts b/src/resources/annotation-queues/items.ts new file mode 100644 index 0000000..cf93a7e --- /dev/null +++ b/src/resources/annotation-queues/items.ts @@ -0,0 +1,396 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { + ItemsCursorGetPagination, + type ItemsCursorGetPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Items extends APIResource { + /** + * Add RUN or THREAD items to a single annotation queue. RUN items require run_id + * unless they are created from a suggested example. THREAD items require thread_id + * and project_id. + */ + create( + queueID: string, + params: ItemCreateParams, + options?: RequestOptions, + ): APIPromise { + const { extend_trace_retention, ...body } = params; + return this._client.post(path`/api/v1/platform/annotation-queues/${queueID}/items`, { + query: { extend_trace_retention }, + body, + ...options, + }); + } + + /** + * Partially update mutable timestamps (added_at, last_reviewed_time) for a RUN or + * THREAD annotation queue item. Omit a field, or pass JSON null, to leave it + * unchanged. + */ + update(itemID: string, params: ItemUpdateParams, options?: RequestOptions): APIPromise { + const { queue_id, ...body } = params; + return this._client.patch(path`/api/v1/platform/annotation-queues/${queue_id}/items/${itemID}`, { + body, + ...options, + }); + } + + /** + * List RUN and THREAD items in a single annotation queue for one review status + * section, with opaque cursor pagination. Optional item_type=RUN|THREAD filters + * the page. Optional min_start_time/max_start_time bound the item's trace start + * time; items with no start time are excluded when either bound is set. + * direction=backward returns items before the supplied cursor. The response + * contains item metadata only, not expanded run or thread payloads. + * status=archived returns items whose queue review requirements have been + * satisfied, not merely items the caller personally marked completed. + */ + list( + queueID: string, + query: ItemListParams, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + path`/api/v1/platform/annotation-queues/${queueID}/items`, + ItemsCursorGetPagination, + { query, ...options }, + ); + } + + /** + * Log the caller's reviewer status for a RUN or THREAD annotation queue item. A + * null status re-shows the item for this reviewer. + */ + createStatus( + queueItemID: string, + body: ItemCreateStatusParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v1/platform/annotation-queues/items/${queueItemID}/status`, { + body, + ...options, + }); + } + + /** + * Remove RUN or THREAD items from a single annotation queue by item ID. Both + * active and completed items can be removed. + */ + deleteAll( + queueID: string, + body: ItemDeleteAllParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v1/platform/annotation-queues/${queueID}/items/delete`, { + body, + ...options, + }); + } + + /** + * Returns the number of annotation queue items in one status bucket. The two time + * windows are independent: start_time/end_time bound when an item was archived, + * min_start_time/max_start_time bound when its trace ran. Items with no trace + * start time are excluded when either of the latter is set. + */ + retrieveCount( + queueID: string, + query: ItemRetrieveCountParams, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v1/platform/annotation-queues/${queueID}/items/count`, { + query, + ...options, + }); + } + + /** + * Resolve a RUN or THREAD item to its current review section and zero-based + * position for deep linking. The returned cursor counts RUN and THREAD items + * together, so it is only valid for a list request with no item_type or start-time + * filter. + */ + retrievePlacement( + itemID: string, + params: ItemRetrievePlacementParams, + options?: RequestOptions, + ): APIPromise { + const { queue_id } = params; + return this._client.get( + path`/api/v1/platform/annotation-queues/${queue_id}/items/${itemID}/placement`, + options, + ); + } +} + +export type ItemListResponsesItemsCursorGetPagination = ItemsCursorGetPagination; + +export interface ItemCreateResponse { + items?: Array; +} + +export namespace ItemCreateResponse { + export interface Item { + id?: string; + + added_at?: string; + + item_type?: 'RUN' | 'THREAD'; + + /** + * LastReviewedTime is always present on the wire (null until reviewed). + */ + last_reviewed_time?: string; + + project_id?: string; + + queue_id?: string; + + run_id?: string; + + source_proposed_example_id?: string; + + start_time?: string; + + thread_id?: string; + } +} + +export interface ItemUpdateResponse { + id?: string; + + added_at?: string; + + item_type?: 'RUN' | 'THREAD'; + + /** + * LastReviewedTime is always present on the wire (null until reviewed). + */ + last_reviewed_time?: string; + + project_id?: string; + + queue_id?: string; + + run_id?: string; + + source_proposed_example_id?: string; + + start_time?: string; + + thread_id?: string; +} + +export interface ItemListResponse { + id?: string; + + added_at?: string; + + completed_by?: Array; + + effective_added_at?: string; + + item_type?: 'RUN' | 'THREAD'; + + /** + * LastReviewedTime is always present on the wire (null until reviewed). + */ + last_reviewed_time?: string; + + project_id?: string; + + queue_id?: string; + + reserved_by?: Array; + + run_id?: string; + + source_proposed_example_id?: string; + + start_time?: string; + + thread_id?: string; +} + +export interface ItemCreateStatusResponse { + is_archived?: boolean; + + override_added_at?: string; + + queue_item_id?: string; + + status?: 'viewed' | 'completed'; +} + +export type ItemDeleteAllResponse = { [key: string]: string }; + +export interface ItemRetrieveCountResponse { + count?: number; +} + +export interface ItemRetrievePlacementResponse { + cursor?: string; + + item_type?: 'RUN' | 'THREAD'; + + position?: number; + + section?: 'needs_my_review' | 'needs_others_review' | 'archived'; +} + +export interface ItemCreateParams { + /** + * Query param: Extend trace retention for added run items + */ + extend_trace_retention?: boolean; + + /** + * Body param + */ + items?: Array; +} + +export namespace ItemCreateParams { + export interface Item { + item_type?: 'RUN' | 'THREAD'; + + project_id?: string; + + /** + * RUN fields + */ + run_id?: string; + + /** + * SessionID is an alias for project_id. + */ + session_id?: string; + + /** + * SourceProposedExampleID links the queue item to the suggested example it was + * created from, when applicable. + */ + source_proposed_example_id?: string; + + start_time?: string; + + thread_id?: string; + } +} + +export interface ItemUpdateParams { + /** + * Path param: Annotation queue ID + */ + queue_id: string; + + /** + * Body param + */ + added_at?: string; + + /** + * Body param + */ + last_reviewed_time?: string; +} + +export interface ItemListParams extends ItemsCursorGetPaginationParams { + /** + * Review section: needs_my_review, needs_others_review, or archived + */ + status: 'needs_my_review' | 'needs_others_review' | 'archived'; + + /** + * Pagination direction. backward requires cursor + */ + direction?: 'forward' | 'backward'; + + /** + * Filter to RUN or THREAD + */ + item_type?: 'RUN' | 'THREAD'; + + /** + * Only items whose trace start time is at or before this timestamp. Omit or send + * the zero time for no bound + */ + max_start_time?: string; + + /** + * Only items whose trace start time is at or after this timestamp. Omit or send + * the zero time for no bound + */ + min_start_time?: string; +} + +export interface ItemCreateStatusParams { + override_added_at?: string; + + status?: 'viewed' | 'completed'; +} + +export interface ItemDeleteAllParams { + item_ids?: Array; +} + +export interface ItemRetrieveCountParams { + /** + * Count bucket: all, needs_my_review, needs_others_review, or archived. + */ + status: string; + + /** + * Archived strictly before this time. Only used when status=archived + */ + end_time?: string; + + /** + * Trace started at or before this time + */ + max_start_time?: string; + + /** + * Trace started at or after this time + */ + min_start_time?: string; + + /** + * Archived strictly after this time. Only used when status=archived + */ + start_time?: string; +} + +export interface ItemRetrievePlacementParams { + /** + * Annotation queue ID + */ + queue_id: string; +} + +export declare namespace Items { + export { + type ItemCreateResponse as ItemCreateResponse, + type ItemUpdateResponse as ItemUpdateResponse, + type ItemListResponse as ItemListResponse, + type ItemCreateStatusResponse as ItemCreateStatusResponse, + type ItemDeleteAllResponse as ItemDeleteAllResponse, + type ItemRetrieveCountResponse as ItemRetrieveCountResponse, + type ItemRetrievePlacementResponse as ItemRetrievePlacementResponse, + type ItemListResponsesItemsCursorGetPagination as ItemListResponsesItemsCursorGetPagination, + type ItemCreateParams as ItemCreateParams, + type ItemUpdateParams as ItemUpdateParams, + type ItemListParams as ItemListParams, + type ItemCreateStatusParams as ItemCreateStatusParams, + type ItemDeleteAllParams as ItemDeleteAllParams, + type ItemRetrieveCountParams as ItemRetrieveCountParams, + type ItemRetrievePlacementParams as ItemRetrievePlacementParams, + }; +} diff --git a/src/resources/annotation-queues/runs.ts b/src/resources/annotation-queues/runs.ts new file mode 100644 index 0000000..30e8660 --- /dev/null +++ b/src/resources/annotation-queues/runs.ts @@ -0,0 +1,313 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as AnnotationQueuesAPI from './annotation-queues'; +import { APIPromise } from '../../core/api-promise'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Runs extends APIResource { + /** + * Add Runs To Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items create endpoint (POST /api/v1/platform/annotation-queues/{queue_id}/items) instead. Will be removed after Jan 31, 2027. + */ + create(queueID: string, params: RunCreateParams, options?: RequestOptions): APIPromise { + const { body, extend_trace_retention } = params; + return this._client.post(path`/api/v1/annotation-queues/${queueID}/runs`, { + query: { extend_trace_retention }, + body: body, + ...options, + }); + } + + /** + * Update Run In Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items update method (PATCH /api/v1/platform/annotation-queues/{queue_id}/items/{item_id}) instead. Will be removed after Jan 31, 2027. + */ + update(queueRunID: string, params: RunUpdateParams, options?: RequestOptions): APIPromise { + const { queue_id, ...body } = params; + return this._client.patch(path`/api/v1/annotation-queues/${queue_id}/runs/${queueRunID}`, { + body, + ...options, + }); + } + + /** + * Get Runs From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items list method (GET /api/v1/platform/annotation-queues/{queue_id}/items) instead. Will be removed after Jan 31, 2027. + */ + list( + queueID: string, + query: RunListParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v1/annotation-queues/${queueID}/runs`, { query, ...options }); + } + + /** + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @deprecated Deprecated: use the annotation queue items create endpoint (POST /api/v1/platform/annotation-queues/{queue_id}/items) instead. Will be removed after Jan 31, 2027. + */ + createByKey( + queueID: string, + params: RunCreateByKeyParams, + options?: RequestOptions, + ): APIPromise { + const { body, extend_trace_retention } = params; + return this._client.post(path`/api/v1/annotation-queues/${queueID}/runs/by-key`, { + query: { extend_trace_retention }, + body: body, + ...options, + }); + } + + /** + * Delete Runs From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items delete_all method (POST /api/v1/platform/annotation-queues/{queue_id}/items/delete) instead. Will be removed after Jan 31, 2027. + */ + deleteAll(queueID: string, body: RunDeleteAllParams, options?: RequestOptions): APIPromise { + return this._client.post(path`/api/v1/annotation-queues/${queueID}/runs/delete`, { body, ...options }); + } + + /** + * Delete Run From Annotation Queue + * + * @deprecated Deprecated: use the annotation queue items delete_all method (POST /api/v1/platform/annotation-queues/{queue_id}/items/delete) with the item ID instead. Will be removed after Jan 31, 2027. + */ + deleteQueue( + queueRunID: string, + params: RunDeleteQueueParams, + options?: RequestOptions, + ): APIPromise { + const { queue_id } = params; + return this._client.delete(path`/api/v1/annotation-queues/${queue_id}/runs/${queueRunID}`, options); + } +} + +export type RunCreateResponse = Array; + +export namespace RunCreateResponse { + export interface RunCreateResponseItem { + id: string; + + queue_id: string; + + run_id: string; + + added_at?: string; + + last_reviewed_time?: string | null; + + source_proposed_example_id?: string | null; + } +} + +export type RunUpdateResponse = unknown; + +export type RunListResponse = Array; + +export type RunCreateByKeyResponse = Array; + +export namespace RunCreateByKeyResponse { + export interface RunCreateByKeyResponseItem { + id: string; + + queue_id: string; + + run_id: string; + + added_at?: string; + + last_reviewed_time?: string | null; + + source_proposed_example_id?: string | null; + } +} + +export type RunDeleteAllResponse = unknown; + +export type RunDeleteQueueResponse = unknown; + +export type RunCreateParams = + | RunCreateParams.RunsUuidArray + | RunCreateParams.RunsAnnotationQueueRunAddSchemaArray + | RunCreateParams.Variant2; + +export declare namespace RunCreateParams { + export interface RunsUuidArray { + /** + * Body param + */ + body: Array; + + /** + * Query param + */ + extend_trace_retention?: boolean; + } + + export interface RunsAnnotationQueueRunAddSchemaArray { + /** + * Body param + */ + body: Array; + + /** + * Query param + */ + extend_trace_retention?: boolean; + } + + export namespace RunsAnnotationQueueRunAddSchemaArray { + /** + * Add a single run to AQ (CH path) with an optional back-pointer to the + * issues-agent proposal that seeded this add. Use when bulk-adding runs that come + * from different proposals — each row carries its own source_proposed_example_id. + * For unrelated bulk adds, prefer plain List[UUID] on the same endpoint. + */ + export interface Body { + run_id: string; + + source_proposed_example_id?: string | null; + } + } + + export interface Variant2 { + /** + * Body param + */ + body: Array; + + /** + * Query param + */ + extend_trace_retention?: boolean; + } + + export namespace Variant2 { + /** + * Deprecated: use plain UUID list or AddRunToQueueByKeyRequest instead. + */ + export interface Body { + /** + * @deprecated + */ + run_id: string; + + /** + * @deprecated + */ + parent_run_id?: string | null; + + /** + * @deprecated + */ + session_id?: string | null; + + /** + * @deprecated + */ + start_time?: string | null; + + /** + * @deprecated + */ + trace_id?: string | null; + + /** + * @deprecated + */ + trace_tier?: 'longlived' | 'shortlived' | null; + } + } +} + +export interface RunUpdateParams { + /** + * Path param + */ + queue_id: string; + + /** + * Body param + */ + added_at?: string | null; + + /** + * Body param + */ + last_reviewed_time?: string | null; +} + +export interface RunListParams { + archived?: boolean | null; + + include_stats?: boolean | null; + + limit?: number; + + offset?: number; + + status?: 'needs_my_review' | 'needs_others_review' | 'completed' | null; +} + +export interface RunCreateByKeyParams { + /** + * Body param + */ + body: Array; + + /** + * Query param + */ + extend_trace_retention?: boolean; +} + +export namespace RunCreateByKeyParams { + /** + * Add run to AQ by SmithDB key. is_root derived server-side (LSAQ-141). + */ + export interface Body { + run_id: string; + + session_id: string; + + start_time: string; + + source_proposed_example_id?: string | null; + } +} + +export interface RunDeleteAllParams { + delete_all?: boolean; + + exclude_run_ids?: Array | null; + + run_ids?: Array | null; +} + +export interface RunDeleteQueueParams { + queue_id: string; +} + +export declare namespace Runs { + export { + type RunCreateResponse as RunCreateResponse, + type RunUpdateResponse as RunUpdateResponse, + type RunListResponse as RunListResponse, + type RunCreateByKeyResponse as RunCreateByKeyResponse, + type RunDeleteAllResponse as RunDeleteAllResponse, + type RunDeleteQueueResponse as RunDeleteQueueResponse, + type RunCreateParams as RunCreateParams, + type RunUpdateParams as RunUpdateParams, + type RunListParams as RunListParams, + type RunCreateByKeyParams as RunCreateByKeyParams, + type RunDeleteAllParams as RunDeleteAllParams, + type RunDeleteQueueParams as RunDeleteQueueParams, + }; +} diff --git a/src/resources/datasets.ts b/src/resources/datasets.ts new file mode 100644 index 0000000..4b86aa0 --- /dev/null +++ b/src/resources/datasets.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './datasets/index'; diff --git a/src/resources/datasets/datasets.ts b/src/resources/datasets/datasets.ts new file mode 100644 index 0000000..48533f1 --- /dev/null +++ b/src/resources/datasets/datasets.ts @@ -0,0 +1,227 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as ExamplesAPI from './examples'; +import { ExampleDeleteParams, Examples } from './examples'; +import * as ExperimentRunsAPI from './experiment-runs'; +import { + ExperimentRunQueryParams, + ExperimentRunQueryResponse, + ExperimentRunQueryResponsesItemsCursorPostPagination, + ExperimentRuns, +} from './experiment-runs'; + +export class Datasets extends APIResource { + examples: ExamplesAPI.Examples = new ExamplesAPI.Examples(this._client); + experimentRuns: ExperimentRunsAPI.ExperimentRuns = new ExperimentRunsAPI.ExperimentRuns(this._client); +} + +/** + * Enum for dataset data types. + */ +export type DataType = 'kv' | 'llm' | 'chat'; + +/** + * Dataset schema. + */ +export interface Dataset { + id: string; + + modified_at: string; + + name: string; + + session_count: number; + + tenant_id: string; + + baseline_experiment_id?: string | null; + + created_at?: string; + + /** + * Enum for dataset data types. + */ + data_type?: DataType | null; + + description?: string | null; + + example_count?: number | null; + + externally_managed?: boolean | null; + + inputs_schema_definition?: { [key: string]: unknown } | null; + + last_session_start_time?: string | null; + + metadata?: { [key: string]: unknown } | null; + + outputs_schema_definition?: { [key: string]: unknown } | null; + + transformations?: Array | null; +} + +export interface DatasetTransformation { + path: Array; + + /** + * Enum for dataset transformation types. Ordering determines the order in which + * transformations are applied if there are multiple transformations on the same + * path. + */ + transformation_type: + | 'convert_to_openai_message' + | 'convert_to_openai_tool' + | 'remove_system_messages' + | 'remove_extra_fields' + | 'extract_tools_from_run'; +} + +/** + * Dataset version schema. + */ +export interface DatasetVersion { + as_of: string; + + tags?: Array | null; +} + +/** + * Schema used for creating feedback without run id or session id. + */ +export interface FeedbackCreateCoreSchema { + key: string; + + id?: string; + + comment?: string | null; + + comparative_experiment_id?: string | null; + + correction?: { [key: string]: unknown } | string | null; + + created_at?: string; + + extra?: { [key: string]: unknown } | null; + + feedback_config?: FeedbackCreateCoreSchema.FeedbackConfig | null; + + feedback_group_id?: string | null; + + /** + * Feedback from the LangChainPlus App. + */ + feedback_source?: + | FeedbackCreateCoreSchema.AppFeedbackSource + | FeedbackCreateCoreSchema.APIFeedbackSource + | FeedbackCreateCoreSchema.ModelFeedbackSource + | FeedbackCreateCoreSchema.AutoEvalFeedbackSource + | null; + + modified_at?: string; + + score?: number | boolean | null; + + value?: number | boolean | string | { [key: string]: unknown } | null; +} + +export namespace FeedbackCreateCoreSchema { + export interface FeedbackConfig { + /** + * Enum for feedback types. + */ + type: 'continuous' | 'categorical' | 'freeform'; + + categories?: Array | null; + + max?: number | null; + + min?: number | null; + } + + export namespace FeedbackConfig { + /** + * Specific value and label pair for feedback + */ + export interface Category { + value: number; + + label?: string | null; + } + } + + /** + * Feedback from the LangChainPlus App. + */ + export interface AppFeedbackSource { + metadata?: { [key: string]: unknown } | null; + + type?: 'app'; + } + + /** + * API feedback source. + */ + export interface APIFeedbackSource { + metadata?: { [key: string]: unknown } | null; + + type?: 'api'; + } + + /** + * Model feedback source. + */ + export interface ModelFeedbackSource { + metadata?: { [key: string]: unknown } | null; + + type?: 'model'; + } + + /** + * Auto eval feedback source. + */ + export interface AutoEvalFeedbackSource { + metadata?: { [key: string]: unknown } | null; + + type?: 'auto_eval'; + } +} + +export interface Missing { + __missing__: '__missing__'; +} + +/** + * Enum for available dataset columns to sort by. + */ +export type SortByDatasetColumn = + | 'name' + | 'created_at' + | 'last_session_start_time' + | 'example_count' + | 'session_count' + | 'modified_at'; + +Datasets.Examples = Examples; +Datasets.ExperimentRuns = ExperimentRuns; + +export declare namespace Datasets { + export { + type DataType as DataType, + type Dataset as Dataset, + type DatasetTransformation as DatasetTransformation, + type DatasetVersion as DatasetVersion, + type FeedbackCreateCoreSchema as FeedbackCreateCoreSchema, + type Missing as Missing, + type SortByDatasetColumn as SortByDatasetColumn, + }; + + export { Examples as Examples, type ExampleDeleteParams as ExampleDeleteParams }; + + export { + ExperimentRuns as ExperimentRuns, + type ExperimentRunQueryResponse as ExperimentRunQueryResponse, + type ExperimentRunQueryResponsesItemsCursorPostPagination as ExperimentRunQueryResponsesItemsCursorPostPagination, + type ExperimentRunQueryParams as ExperimentRunQueryParams, + }; +} diff --git a/src/resources/datasets/examples.ts b/src/resources/datasets/examples.ts new file mode 100644 index 0000000..16715e0 --- /dev/null +++ b/src/resources/datasets/examples.ts @@ -0,0 +1,36 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Examples extends APIResource { + /** + * Soft-delete an example, preserving prior versions and their attachments. If the + * latest version is already deleted, the request succeeds without creating another + * version. Deletion is recorded at the current time or just after the latest + * version, whichever is later. For future-dated versions, latest reads reflect + * deletion immediately; timestamp reads reflect deletion only at or after the + * recorded deletion timestamp. + */ + delete(exampleID: string, params: ExampleDeleteParams, options?: RequestOptions): APIPromise { + const { dataset_id } = params; + return this._client.delete(path`/api/v1/platform/datasets/${dataset_id}/examples/${exampleID}`, { + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } +} + +export interface ExampleDeleteParams { + /** + * Dataset ID + */ + dataset_id: string; +} + +export declare namespace Examples { + export { type ExampleDeleteParams as ExampleDeleteParams }; +} diff --git a/src/resources/datasets/experiment-runs.ts b/src/resources/datasets/experiment-runs.ts new file mode 100644 index 0000000..66013cc --- /dev/null +++ b/src/resources/datasets/experiment-runs.ts @@ -0,0 +1,153 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as RunsAPI from '../runs/runs'; +import { + ItemsCursorPostPagination, + type ItemsCursorPostPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class ExperimentRuns extends APIResource { + /** + * Returns a paginated page of dataset examples with runs from the requested + * experiments. Response uses the canonical `{items, next_cursor}` envelope. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + */ + query( + datasetID: string, + body: ExperimentRunQueryParams, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + path`/api/v2/datasets/${datasetID}/experiment-runs`, + ItemsCursorPostPagination, + { body, method: 'post', ...options }, + ); + } +} + +export type ExperimentRunQueryResponsesItemsCursorPostPagination = + ItemsCursorPostPagination; + +export interface ExperimentRunQueryResponse { + /** + * `id` is the dataset example UUID. + */ + id?: string; + + /** + * `attachment_urls` maps each attachment name to a pre-signed download URL. + */ + attachment_urls?: unknown; + + /** + * `created_at` is when the example was created (RFC3339 date-time). + */ + created_at?: string; + + /** + * `dataset_id` is the parent dataset UUID. + */ + dataset_id?: string; + + /** + * `inputs` is the example input payload (arbitrary JSON object). + */ + inputs?: unknown; + + /** + * `metadata` is arbitrary user-defined JSON metadata on the example. + */ + metadata?: unknown; + + /** + * `modified_at` is when the example was last modified (RFC3339 date-time). + */ + modified_at?: string; + + /** + * `name` is the example's optional name. + */ + name?: string; + + /** + * `outputs` is the example reference-output payload (arbitrary JSON object). + */ + outputs?: unknown; + + /** + * `runs` is the list of experiment runs produced for this example. + */ + runs?: Array; + + /** + * `source_run_id` is the run UUID the example was created from, if any. + */ + source_run_id?: string; +} + +export interface ExperimentRunQueryParams extends ItemsCursorPostPaginationParams { + /** + * `comparative_experiment_id` scopes pairwise-annotation feedback (optional). + */ + comparative_experiment_id?: string; + + /** + * `example_ids` optionally restricts the page to these dataset example UUIDs (max + * 1000). + */ + example_ids?: Array; + + /** + * `experiment_ids` lists the experiment (tracing session) UUIDs to query. + * Required, non-empty. + */ + experiment_ids?: Array; + + /** + * `filters` maps a project (session) UUID string to a list of filter expressions + * (optional). + */ + filters?: { [key: string]: Array }; + + /** + * `selects` lists which run properties to include. Omitted => only `id`. Tokens + * mirror /v2/runs/query. + */ + selects?: Array; + + /** + * `sort` controls feedback-score sorting (single project only). + */ + sort?: ExperimentRunQueryParams.Sort; +} + +export namespace ExperimentRunQueryParams { + /** + * `sort` controls feedback-score sorting (single project only). + */ + export interface Sort { + /** + * `by` is the feedback selector, e.g. `feedback.correctness` (the `feedback.` + * prefix is optional). + */ + by?: string; + + /** + * `order` is `ASC` or `DESC` (defaults to `DESC`). + */ + order?: string; + } +} + +export declare namespace ExperimentRuns { + export { + type ExperimentRunQueryResponse as ExperimentRunQueryResponse, + type ExperimentRunQueryResponsesItemsCursorPostPagination as ExperimentRunQueryResponsesItemsCursorPostPagination, + type ExperimentRunQueryParams as ExperimentRunQueryParams, + }; +} diff --git a/src/resources/datasets/index.ts b/src/resources/datasets/index.ts new file mode 100644 index 0000000..0213a08 --- /dev/null +++ b/src/resources/datasets/index.ts @@ -0,0 +1,19 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + Datasets, + type DataType, + type Dataset, + type DatasetTransformation, + type DatasetVersion, + type FeedbackCreateCoreSchema, + type Missing, + type SortByDatasetColumn, +} from './datasets'; +export { Examples, type ExampleDeleteParams } from './examples'; +export { + ExperimentRuns, + type ExperimentRunQueryResponse, + type ExperimentRunQueryParams, + type ExperimentRunQueryResponsesItemsCursorPostPagination, +} from './experiment-runs'; diff --git a/src/resources/fleet.ts b/src/resources/fleet.ts new file mode 100644 index 0000000..a61639f --- /dev/null +++ b/src/resources/fleet.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './fleet/index'; diff --git a/src/resources/fleet/fleet.ts b/src/resources/fleet/fleet.ts new file mode 100644 index 0000000..2bf4c22 --- /dev/null +++ b/src/resources/fleet/fleet.ts @@ -0,0 +1,15 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as ThreadsAPI from './threads'; +import { ThreadActivateSandboxResponse, Threads } from './threads'; + +export class Fleet extends APIResource { + threads: ThreadsAPI.Threads = new ThreadsAPI.Threads(this._client); +} + +Fleet.Threads = Threads; + +export declare namespace Fleet { + export { Threads as Threads, type ThreadActivateSandboxResponse as ThreadActivateSandboxResponse }; +} diff --git a/src/resources/fleet/index.ts b/src/resources/fleet/index.ts new file mode 100644 index 0000000..fba530d --- /dev/null +++ b/src/resources/fleet/index.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { Fleet } from './fleet'; +export { Threads, type ThreadActivateSandboxResponse } from './threads'; diff --git a/src/resources/fleet/threads.ts b/src/resources/fleet/threads.ts new file mode 100644 index 0000000..93bbd52 --- /dev/null +++ b/src/resources/fleet/threads.ts @@ -0,0 +1,29 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Threads extends APIResource { + /** + * Starts or resumes the sandbox referenced by the thread and returns when it is + * ready. The operation is idempotent. The thread must include + * sandbox.sandbox_slug. + */ + activateSandbox(threadID: string, options?: RequestOptions): APIPromise { + return this._client.post(path`/v1/fleet/threads/${threadID}/sandbox-activation`, options); + } +} + +export interface ThreadActivateSandboxResponse { + sandbox_slug: string; + + scope: 'agent' | 'thread'; + + status: 'provisioning' | 'ready' | 'failed' | 'stopped' | 'deleting'; +} + +export declare namespace Threads { + export { type ThreadActivateSandboxResponse as ThreadActivateSandboxResponse }; +} diff --git a/src/resources/index.ts b/src/resources/index.ts new file mode 100644 index 0000000..80ca91d --- /dev/null +++ b/src/resources/index.ts @@ -0,0 +1,137 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './shared'; +export { + AnnotationQueues, + type AnnotationQueueRubricItemSchema, + type AnnotationQueueSchema, + type AnnotationQueueSizeSchema, + type RunSchemaWithAnnotationQueueInfo, + type AnnotationQueueRetrieveResponse, + type AnnotationQueueUpdateResponse, + type AnnotationQueueDeleteResponse, + type AnnotationQueueCreateRunStatusResponse, + type AnnotationQueueExportResponse, + type AnnotationQueuePopulateResponse, + type AnnotationQueueRetrieveAnnotationQueuesResponse, + type AnnotationQueueRetrieveQueuesResponse, + type AnnotationQueueUpdateParams, + type AnnotationQueueAnnotationQueuesParams, + type AnnotationQueueCreateRunStatusParams, + type AnnotationQueueExportParams, + type AnnotationQueuePopulateParams, + type AnnotationQueueRetrieveAnnotationQueuesParams, + type AnnotationQueueRetrieveRunParams, + type AnnotationQueueRetrieveSizeParams, + type AnnotationQueueRetrieveTotalArchivedParams, + type AnnotationQueueRetrieveAnnotationQueuesResponsesOffsetPaginationTopLevelArray, +} from './annotation-queues/annotation-queues'; +export { + Datasets, + type DataType, + type Dataset, + type DatasetTransformation, + type DatasetVersion, + type FeedbackCreateCoreSchema, + type Missing, + type SortByDatasetColumn, +} from './datasets/datasets'; +export { Fleet } from './fleet/fleet'; +export { Info, type InfoListResponse } from './info'; +export { + Issues, + type Issue, + type IssueRetrieveParams, + type IssueListParams, + type IssuesOffsetPaginationIssues, +} from './issues'; +export { + OnlineEvaluators, + type BulkDeleteEvaluatorFailedItem, + type BulkDeleteEvaluatorsResponse, + type CreateOnlineCodeEvaluatorRequest, + type CreateOnlineEvaluatorRequest, + type CreateOnlineEvaluatorResponse, + type CreateOnlineLlmEvaluatorRequest, + type GetOnlineEvaluatorSpendResponse, + type OnlineCodeEvaluator, + type OnlineEvaluator, + type OnlineEvaluatorRunRule, + type OnlineEvaluatorSpendDay, + type OnlineEvaluatorSpendGroup, + type OnlineEvaluatorType, + type OnlineLlmEvaluator, + type OnlineSpendLimit, + type UpdateOnlineCodeEvaluatorRequest, + type UpdateOnlineEvaluatorRequest, + type UpdateOnlineEvaluatorResponse, + type UpdateOnlineLlmEvaluatorRequest, + type OnlineEvaluatorCreateParams, + type OnlineEvaluatorUpdateParams, + type OnlineEvaluatorListParams, + type OnlineEvaluatorDeleteParams, + type OnlineEvaluatorBulkDeleteParams, + type OnlineEvaluatorSpendParams, + type OnlineEvaluatorsOffsetPaginationOnlineEvaluators, +} from './online-evaluators'; +export { + ProductFeedback, + type ProductFeedbackCreateResponse, + type ProductFeedbackRetrieveResponse, + type ProductFeedbackCreateParams, +} from './product-feedback'; +export { Public } from './public/public'; +export { + Runs, + type ResponseBodyForRunsGenerateQuery, + type Run, + type RunIngest, + type RunSchema, + type RunSelectField, + type RunStatsQueryParams, + type RunType, + type RunTypeEnum, + type RunsFilterDataSourceTypeEnum, + type RunGetURLResponse, + type RunGetURLParams, + type RunQueryV2Params, + type RunRetrieveV2Params, + type RunsItemsCursorPostPagination, +} from './runs/runs'; +export { + Sandboxes, + type DownloadURLResponse, + type SandboxListResponse, + type SandboxResponse, + type SandboxStatusResponse, + type ServiceURLResponse, + type SnapshotListResponse, + type SnapshotResponse, + type SandboxListUsageCostsResponse, + type SandboxListUsageCostsParams, + type SandboxResponsesItemsCursorGetPagination, + type SnapshotResponsesItemsCursorGetPagination, + type SandboxListUsageCostsResponsesItemsCursorGetPagination, +} from './sandboxes/sandboxes'; +export { + Threads, + type Thread, + type ThreadStats, + type ThreadTrace, + type ThreadAggregateStatsResponse, + type ThreadAggregateStatsParams, + type ThreadListTracesParams, + type ThreadQueryParams, + type ThreadStatsParams, + type ThreadTracesItemsCursorGetPagination, + type ThreadsItemsCursorPostPagination, +} from './threads/threads'; +export { + Traces, + type Trace, + type TraceAggregates, + type TraceListRunsResponse, + type TraceListRunsParams, + type TraceQueryParams, + type TracesItemsCursorPostPagination, +} from './traces'; diff --git a/src/resources/info.ts b/src/resources/info.ts new file mode 100644 index 0000000..7a4c7a2 --- /dev/null +++ b/src/resources/info.ts @@ -0,0 +1,76 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { RequestOptions } from '../internal/request-options'; + +export class Info extends APIResource { + /** + * Returns information about the current LangSmith deployment: version, instance + * feature flags, batch-ingest limits, and max SDK versions. Unauthenticated by + * default; set FF_INFO_ENDPOINT_AUTH_REQUIRED=true to require auth. + */ + list(options?: RequestOptions): APIPromise { + return this._client.get('/api/v1/info', options); + } +} + +export interface InfoListResponse { + batch_ingest_config?: InfoListResponse.BatchIngestConfig; + + /** + * BillingInstallationID is the persistent per-installation identity for + * self-hosted deployments. + */ + billing_installation_id?: string; + + customer_info?: InfoListResponse.CustomerInfo; + + engine_github_web_base_url?: string; + + git_sha?: string; + + instance_flags?: { [key: string]: unknown }; + + license_expiration_time?: string; + + sdk_versions?: InfoListResponse.SDKVersions; + + version?: string; +} + +export namespace InfoListResponse { + export interface BatchIngestConfig { + scale_down_nempty_trigger?: number; + + scale_up_nthreads_limit?: number; + + scale_up_qsize_trigger?: number; + + size_limit?: number; + + size_limit_bytes?: number; + + use_multipart_endpoint?: boolean; + } + + export interface CustomerInfo { + customer_id?: string; + + customer_name?: string; + } + + export interface SDKVersions { + max_go_sdk_version?: string; + + max_java_sdk_version?: string; + + max_js_sdk_version?: string; + + max_python_sdk_version?: string; + } +} + +export declare namespace Info { + export { type InfoListResponse as InfoListResponse }; +} diff --git a/src/resources/issues.ts b/src/resources/issues.ts new file mode 100644 index 0000000..7390468 --- /dev/null +++ b/src/resources/issues.ts @@ -0,0 +1,466 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { OffsetPaginationIssues, type OffsetPaginationIssuesParams, PagePromise } from '../core/pagination'; +import { RequestOptions } from '../internal/request-options'; +import { path } from '../internal/utils/path'; + +export class Issues extends APIResource { + /** + * **Beta:** This endpoint is in active development and may change without notice. + * + * Returns one issue for the authenticated tenant. + */ + retrieve( + id: string, + query: IssueRetrieveParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v1/platform/issues/${id}`, { query, ...options }); + } + + /** + * **Beta:** This endpoint is in active development and may change without notice. + * + * Returns issues for the authenticated tenant, optionally filtered by session, + * status, severity, tag, linked trace, or last modified time. + */ + list( + query: IssueListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList('/api/v1/platform/issues', OffsetPaginationIssues, { + query, + ...options, + }); + } +} + +export type IssuesOffsetPaginationIssues = OffsetPaginationIssues; + +export interface Issue { + id?: string; + + actions?: unknown; + + auto_resolution_evidence?: unknown; + + /** + * Nil unless eligible: "auto_close" or "prompt". Evidence carries the deciding + * gate. + */ + auto_resolution_state?: string; + + created_at?: string; + + description?: string; + + /** + * Nil for the trace-list issues that are the norm. + */ + evidence?: Issue.Evidence | null; + + first_seen_at?: string; + + /** + * Legacy: branch of the oldest fix in the board's oldest connected repository. + */ + fix_branch?: string; + + fix_dispatched_at?: string; + + /** + * Non-nil once the issue is handed off to a coding agent; Engine skips its own fix + * run while it is set. + */ + fix_handoff_bot_user_id?: string | null; + + fix_pr_number?: number; + + /** + * Issue-level: the problem every fix shares, and the last time a fix run was + * dispatched for this issue — one run works several fixes. + */ + fix_prompt?: string; + + fix_verification?: Issue.FixVerification; + + /** + * Newest first. + */ + fixes?: Array; + + last_seen_at?: string; + + linear_context?: Issue.LinearContext; + + linear_sync?: Issue.LinearSync; + + name?: string; + + proposed_context_fixes?: Array; + + proposed_examples?: Array; + + proposed_fix?: string; + + proposed_prompt_fixes?: Array; + + /** + * RecurrencesSinceWatching counts linked traces whose run start_time is after + * watching_since — i.e. recurrences observed during the current watch period. + */ + recurrences_since_watching?: number; + + session_id?: string; + + severity?: 0 | 1 | 2 | 3; + + status?: 'open' | 'fixing' | 'watching' | 'completed' | 'ignored'; + + tags?: Array; + + tenant_id?: string; + + traces?: unknown; + + updated_at?: string; + + validation_result?: Issue.ValidationResult; + + watching_since?: string; +} + +export namespace Issue { + /** + * Nil for the trace-list issues that are the norm. + */ + export interface Evidence { + type: 'series'; + + series?: Evidence.Series; + } + + export namespace Evidence { + export interface Series { + metric_definition: Series.MetricDefinition; + + /** + * Narrows what is measured; the renderer ANDs its root scope over it. + */ + run_filter?: string; + + window_end?: string; + + /** + * The view the chart opens at, not a clamp. Start alone renders start -> now. + */ + window_start?: string; + } + + export namespace Series { + export interface MetricDefinition { + /** + * histogram is reserved and rejected; the tag publishes what is accepted. + */ + type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'percentile' | 'ratio' | 'histogram'; + + denominator?: MetricDefinition.Denominator; + + /** + * Entity selects what a type=count metric counts. Only valid when type=count; + * defaults to MetricEntityRun. entity=feedback requires params.feedback_key and + * counts individual feedback records rather than runs. + */ + entity?: 'run' | 'feedback'; + + field?: + | 'latency_seconds' + | 'first_token_seconds' + | 'total_tokens' + | 'prompt_tokens' + | 'completion_tokens' + | 'total_cost' + | 'prompt_cost' + | 'completion_cost' + | 'feedback_score'; + + /** + * Numerator and Denominator are required when type=ratio. + */ + numerator?: MetricDefinition.Numerator; + + /** + * percentile p or histogram bucket_count + */ + params?: MetricDefinition.Params; + } + + export namespace MetricDefinition { + export interface Denominator { + /** + * An operand is non-composite, so ratio is rejected here too. + */ + type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'percentile' | 'ratio' | 'histogram'; + + /** + * Entity selects what a type=count metric counts. Only valid when type=count; + * defaults to MetricEntityRun. entity=feedback requires params.feedback_key and + * counts individual feedback records rather than runs. + */ + entity?: 'run' | 'feedback'; + + field?: + | 'latency_seconds' + | 'first_token_seconds' + | 'total_tokens' + | 'prompt_tokens' + | 'completion_tokens' + | 'total_cost' + | 'prompt_cost' + | 'completion_cost' + | 'feedback_score'; + + filter?: string; + + /** + * required when type=percentile + */ + params?: Denominator.Params; + } + + export namespace Denominator { + /** + * required when type=percentile + */ + export interface Params { + bucket_count?: number; + + feedback_key?: string; + + p?: number; + } + } + + /** + * Numerator and Denominator are required when type=ratio. + */ + export interface Numerator { + /** + * An operand is non-composite, so ratio is rejected here too. + */ + type: 'count' | 'sum' | 'avg' | 'min' | 'max' | 'percentile' | 'ratio' | 'histogram'; + + /** + * Entity selects what a type=count metric counts. Only valid when type=count; + * defaults to MetricEntityRun. entity=feedback requires params.feedback_key and + * counts individual feedback records rather than runs. + */ + entity?: 'run' | 'feedback'; + + field?: + | 'latency_seconds' + | 'first_token_seconds' + | 'total_tokens' + | 'prompt_tokens' + | 'completion_tokens' + | 'total_cost' + | 'prompt_cost' + | 'completion_cost' + | 'feedback_score'; + + filter?: string; + + /** + * required when type=percentile + */ + params?: Numerator.Params; + } + + export namespace Numerator { + /** + * required when type=percentile + */ + export interface Params { + bucket_count?: number; + + feedback_key?: string; + + p?: number; + } + } + + /** + * percentile p or histogram bucket_count + */ + export interface Params { + bucket_count?: number; + + feedback_key?: string; + + p?: number; + } + } + } + } + + export interface FixVerification { + attempt?: number; + + baseline_experiment_id?: string; + + dataset_id?: string; + + parent_deployment_id?: string; + + preview_deployment_id?: string; + + preview_experiment_id?: string; + + reason?: string; + + root_trace_ids?: Array; + + status?: 'awaiting_preview' | 'verifying' | 'passed' | 'failed' | 'inconclusive' | 'timeout' | 'error'; + + updated_at?: string; + } + + export interface Fix { + id: string; + + branch: string | null; + + created_at: string; + + pr_number: number | null; + + repo_url: string; + + updated_at: string; + } + + export interface LinearContext { + github_pr_urls?: Array; + + workflow_state?: string; + } + + export interface LinearSync { + identifier?: string; + + issue_id?: string; + + last_attempted_at?: string; + + last_error?: string; + + last_synced_at?: string; + + linear_issue_id?: string; + + state?: 'pending' | 'synced' | 'failed' | 'auth_required' | 'paused'; + + url?: string; + } + + export interface ValidationResult { + active_revision_id?: string; + + baseline_experiment_id?: string; + + completed_at?: string; + + dataset_id?: string; + + deployment_id?: string; + + outcome?: 'reproduced' | 'not_reproduced' | 'inconclusive' | 'error'; + + reason?: string; + + root_trace_ids?: Array; + } +} + +export interface IssueRetrieveParams { + /** + * Include current Linear workflow state and validated linked GitHub pull request + * URLs + */ + include_linear_context?: boolean; +} + +export interface IssueListParams extends OffsetPaginationIssuesParams { + /** + * Filter by Engine activity (repeatable; OR semantics) + */ + activity?: Array<'fixing' | 'watching' | 'recurred'>; + + /** + * Filter by session ID (UUID) + */ + session_id?: string; + + /** + * Filter by session name (exact match) + */ + session_name?: string; + + /** + * Filter by severity + */ + severity?: 0 | 1 | 2 | 3; + + /** + * Filter by exact severity (repeatable; OR semantics) + */ + severity_exact?: Array<0 | 1 | 2 | 3>; + + /** + * Sort field + */ + sort_by?: + | 'default' + | 'created_at' + | 'updated_at' + | 'last_seen' + | 'last_updated' + | 'trace_count' + | 'severity'; + + /** + * Filter by status + */ + status?: 'open' | 'fixing' | 'watching' | 'completed' | 'ignored'; + + /** + * Group results by issue lifecycle status before applying sort_by + */ + status_first?: boolean; + + /** + * Filter by tag (exact match) + */ + tag?: string; + + /** + * Return only issues with a linked run in this trace + */ + trace_id?: string; + + /** + * Return only issues updated at or after this RFC3339 timestamp + */ + updated_at?: string; +} + +export declare namespace Issues { + export { + type Issue as Issue, + type IssuesOffsetPaginationIssues as IssuesOffsetPaginationIssues, + type IssueRetrieveParams as IssueRetrieveParams, + type IssueListParams as IssueListParams, + }; +} diff --git a/src/resources/online-evaluators.ts b/src/resources/online-evaluators.ts new file mode 100644 index 0000000..f128fe0 --- /dev/null +++ b/src/resources/online-evaluators.ts @@ -0,0 +1,587 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { + OffsetPaginationOnlineEvaluators, + type OffsetPaginationOnlineEvaluatorsParams, + PagePromise, +} from '../core/pagination'; +import { buildHeaders } from '../internal/headers'; +import { RequestOptions } from '../internal/request-options'; +import { path } from '../internal/utils/path'; + +export class OnlineEvaluators extends APIResource { + /** + * Create a new LLM or code evaluator for the current workspace. + */ + create( + body: OnlineEvaluatorCreateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post('/api/v1/platform/evaluators', { body, ...options }); + } + + /** + * Retrieve a single evaluator by its ID. + */ + retrieve(evaluatorID: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v1/platform/evaluators/${evaluatorID}`, options); + } + + /** + * Update an existing evaluator's name, LLM configuration, or code configuration. + * Returns 409 when a code evaluator build is ENQUEUED or BUILDING. + */ + update( + evaluatorID: string, + body: OnlineEvaluatorUpdateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.patch(path`/api/v1/platform/evaluators/${evaluatorID}`, { body, ...options }); + } + + /** + * List evaluators for the current workspace, with optional filtering by type, + * name, tag, feedback key, or resource ID. + */ + list( + query: OnlineEvaluatorListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + '/api/v1/platform/evaluators', + OffsetPaginationOnlineEvaluators, + { query, ...options }, + ); + } + + /** + * Delete an evaluator. Returns 409 when a code evaluator build is ENQUEUED or + * BUILDING, or when run rules still reference the evaluator and delete_run_rules + * is false. When delete_run_rules is true, all run rules referencing this + * evaluator are deleted first (same tenant) if the build is not in flight. + * Associated llm_evaluators and code_evaluators rows are removed by foreign-key + * cascade when the evaluator row is deleted. + */ + delete( + evaluatorID: string, + params: OnlineEvaluatorDeleteParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { delete_run_rules } = params ?? {}; + return this._client.delete(path`/api/v1/platform/evaluators/${evaluatorID}`, { + query: { delete_run_rules }, + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } + + /** + * Delete multiple evaluators by their IDs. Returns per-item success/failure. + */ + bulkDelete( + params: OnlineEvaluatorBulkDeleteParams, + options?: RequestOptions, + ): APIPromise { + const { evaluator_ids, delete_run_rules } = params; + return this._client.delete('/api/v1/platform/evaluators', { + query: { evaluator_ids, delete_run_rules }, + ...options, + }); + } + + /** + * Returns per-day LLM evaluator spend for the requested 7-day period, grouped by + * evaluator, resource, or run rule. Exactly one of group_by, evaluator_id, + * session_id, or dataset_id is required. resource_id, type, feedback_key, and + * tag_value_id may be supplied with group_by to narrow listing aggregations. + */ + spend( + query: OnlineEvaluatorSpendParams, + options?: RequestOptions, + ): APIPromise { + return this._client.get('/api/v1/platform/evaluators/spend', { query, ...options }); + } +} + +export type OnlineEvaluatorsOffsetPaginationOnlineEvaluators = + OffsetPaginationOnlineEvaluators; + +export interface BulkDeleteEvaluatorFailedItem { + id?: string; + + error?: string; +} + +export interface BulkDeleteEvaluatorsResponse { + failed?: Array; + + succeeded?: Array; +} + +export interface CreateOnlineCodeEvaluatorRequest { + advanced_features_enabled?: boolean; + + code?: string; + + dependencies?: string | null; + + /** + * Default: "python" + */ + language?: string; + + managed_code_evaluator_key?: 'voice_metrics'; + + managed_code_evaluator_settings?: { + [key: string]: CreateOnlineCodeEvaluatorRequest.ManagedCodeEvaluatorSettings; + }; + + /** + * RequireAttachments opts the evaluator into selecting/presigning run attachments + * (s3_urls) at evaluation time. Default false. + */ + require_attachments?: boolean; +} + +export namespace CreateOnlineCodeEvaluatorRequest { + export interface ManagedCodeEvaluatorSettings { + is_enabled?: boolean; + + key_name?: string; + } +} + +export interface CreateOnlineEvaluatorRequest { + code_evaluator?: CreateOnlineCodeEvaluatorRequest; + + llm_evaluator?: CreateOnlineLlmEvaluatorRequest; + + name?: string; + + type?: OnlineEvaluatorType; +} + +export interface CreateOnlineEvaluatorResponse { + evaluator?: OnlineEvaluator; +} + +export interface CreateOnlineLlmEvaluatorRequest { + commit_hash_or_tag?: string; + + /** + * Model Configuration ID + */ + playground_settings_id?: string; + + prompt_repo_handle?: string; + + variable_mapping?: unknown; +} + +export interface GetOnlineEvaluatorSpendResponse { + groups?: Array; + + period_end?: string; + + period_start?: string; +} + +export interface OnlineCodeEvaluator { + advanced_features_enabled?: boolean; + + code?: string; + + dependencies?: string; + + evaluator_build_error?: string; + + evaluator_build_status?: 'ENQUEUED' | 'BUILDING' | 'READY' | 'FAILED'; + + evaluator_id?: string; + + /** + * Default: "python" + */ + language?: string; + + managed_code_evaluator_key?: 'voice_metrics'; + + managed_code_evaluator_settings?: { [key: string]: OnlineCodeEvaluator.ManagedCodeEvaluatorSettings }; + + /** + * RequireAttachments opts the evaluator into selecting/presigning run attachments + * (s3_urls) at evaluation time. Default false. + */ + require_attachments?: boolean; +} + +export namespace OnlineCodeEvaluator { + export interface ManagedCodeEvaluatorSettings { + is_enabled?: boolean; + + key_name?: string; + } +} + +export interface OnlineEvaluator { + id?: string; + + code_evaluator?: OnlineCodeEvaluator; + + created_at?: string; + + created_by?: string; + + feedback_keys?: Array; + + /** + * IsManaged marks a LangChain Tuned Evaluator (currently the tuned Perceived Error + * judge). NULL in the DB is read as false via COALESCE. + */ + is_managed?: boolean; + + /** + * Embedded child evaluator (populated based on type) + */ + llm_evaluator?: OnlineLlmEvaluator; + + name?: string; + + run_rules?: Array; + + tenant_id?: string; + + type?: OnlineEvaluatorType; + + updated_at?: string; +} + +export interface OnlineEvaluatorRunRule { + id?: string; + + corrections_dataset_id?: string; + + dataset_id?: string; + + dataset_name?: string; + + group_by?: string; + + num_few_shot_examples?: number; + + session_id?: string; + + session_name?: string; + + /** + * SpendLimit is the effective spend-cap limit for this rule (nil when + * unconfigured). + */ + spend_limit?: OnlineSpendLimit; + + /** + * Per-rule usage for the current ISO week (omitted when feature is disabled). + * LLM-evaluator rules are initialized to 0; code-evaluator rules include trace + * counts only. + */ + spend_usd?: number; + + trace_count?: number; + + use_corrections_dataset?: boolean; +} + +export interface OnlineEvaluatorSpendDay { + date?: string; + + spend_usd?: number; + + trace_count?: number; +} + +export interface OnlineEvaluatorSpendGroup { + dataset_id?: string; + + dataset_name?: string; + + days?: Array; + + evaluator_id?: string; + + evaluator_name?: string; + + prev_total_spend_usd?: number; + + prev_total_trace_count?: number; + + run_rule_id?: string; + + run_rule_name?: string; + + session_id?: string; + + session_name?: string; + + spend_limit?: OnlineSpendLimit; + + total_spend_usd?: number; + + total_trace_count?: number; +} + +export type OnlineEvaluatorType = 'llm' | 'code'; + +export interface OnlineLlmEvaluator { + annotation_queue_id?: string; + + commit_hash_or_tag?: string; + + corrections_dataset_id?: string; + + evaluator_id?: string; + + num_few_shot_examples?: number; + + prompt_id?: string; + + prompt_repo_handle?: string; + + /** + * Derived from the evaluator's run rules — shared across all rules on this + * evaluator. Nil when the evaluator has no run rules. + */ + use_corrections_dataset?: boolean; + + /** + * JSONB + */ + variable_mapping?: unknown; +} + +export interface OnlineSpendLimit { + limit_usd?: number; + + utilization_pct?: number; + + window?: string; +} + +export interface UpdateOnlineCodeEvaluatorRequest { + advanced_features_enabled?: boolean; + + code?: string; + + dependencies?: string | null; + + language?: string; + + managed_code_evaluator_settings?: { + [key: string]: UpdateOnlineCodeEvaluatorRequest.ManagedCodeEvaluatorSettings; + }; + + /** + * RequireAttachments is fetch-time config: updating it does not rebuild the + * sandbox snapshot. + */ + require_attachments?: boolean; +} + +export namespace UpdateOnlineCodeEvaluatorRequest { + export interface ManagedCodeEvaluatorSettings { + is_enabled?: boolean; + + key_name?: string; + } +} + +export interface UpdateOnlineEvaluatorRequest { + code_evaluator?: UpdateOnlineCodeEvaluatorRequest; + + llm_evaluator?: UpdateOnlineLlmEvaluatorRequest; + + name?: string; +} + +export interface UpdateOnlineEvaluatorResponse { + evaluator?: OnlineEvaluator; +} + +export interface UpdateOnlineLlmEvaluatorRequest { + commit_hash_or_tag?: string; + + num_few_shot_examples?: number; + + /** + * Model Configuration ID + */ + playground_settings_id?: string; + + prompt_repo_handle?: string; + + use_corrections_dataset?: boolean; + + variable_mapping?: unknown; +} + +export interface OnlineEvaluatorCreateParams { + code_evaluator?: CreateOnlineCodeEvaluatorRequest; + + llm_evaluator?: CreateOnlineLlmEvaluatorRequest; + + name?: string; + + type?: OnlineEvaluatorType; +} + +export interface OnlineEvaluatorUpdateParams { + code_evaluator?: UpdateOnlineCodeEvaluatorRequest; + + llm_evaluator?: UpdateOnlineLlmEvaluatorRequest; + + name?: string; +} + +export interface OnlineEvaluatorListParams extends OffsetPaginationOnlineEvaluatorsParams { + /** + * Filter to evaluators attached to the agent's environments or tagged datasets + */ + agent_id?: string; + + /** + * Filter by feedback key + */ + feedback_key?: string; + + /** + * Filter by name substring (also searches creator names) + */ + name_contains?: string; + + /** + * Filter by resource IDs + */ + resource_id?: Array; + + /** + * Field to sort by + */ + sort_by?: string; + + /** + * Sort in descending order + */ + sort_by_desc?: boolean; + + /** + * Filter by tag value IDs + */ + tag_value_id?: Array; + + /** + * Filter by evaluator type + */ + type?: string; +} + +export interface OnlineEvaluatorDeleteParams { + /** + * When true, delete all run rules for this evaluator before deleting the evaluator + */ + delete_run_rules?: boolean; +} + +export interface OnlineEvaluatorBulkDeleteParams { + /** + * Evaluator IDs to delete + */ + evaluator_ids: Array; + + /** + * When true, delete all run rules for this evaluator before deleting the evaluator + */ + delete_run_rules?: boolean; +} + +export interface OnlineEvaluatorSpendParams { + /** + * Start of the 7-day window (YYYY-MM-DD). + */ + period_start: string; + + /** + * Filter to a specific dataset (UUID). Mutually exclusive with group_by. + */ + dataset_id?: string; + + /** + * Filter to a specific evaluator (UUID). Mutually exclusive with group_by. + */ + evaluator_id?: string; + + /** + * Filter grouped results by evaluator feedback key. Only valid with group_by. + */ + feedback_key?: string; + + /** + * Aggregation mode: 'evaluator', 'resource', or 'run_rule'. Mutually exclusive + * with entity filters. + */ + group_by?: string; + + /** + * Filter grouped results to evaluators attached to all supplied project or dataset + * IDs. Only valid with group_by. + */ + resource_id?: Array; + + /** + * Filter to a specific project (UUID). Mutually exclusive with group_by. + */ + session_id?: string; + + /** + * Filter grouped results to evaluators, projects, or datasets tagged with all + * supplied tag value IDs. Only valid with group_by. + */ + tag_value_id?: Array; + + /** + * Filter grouped results by evaluator type: 'llm' or 'code'. Only valid with + * group_by. + */ + type?: string; +} + +export declare namespace OnlineEvaluators { + export { + type BulkDeleteEvaluatorFailedItem as BulkDeleteEvaluatorFailedItem, + type BulkDeleteEvaluatorsResponse as BulkDeleteEvaluatorsResponse, + type CreateOnlineCodeEvaluatorRequest as CreateOnlineCodeEvaluatorRequest, + type CreateOnlineEvaluatorRequest as CreateOnlineEvaluatorRequest, + type CreateOnlineEvaluatorResponse as CreateOnlineEvaluatorResponse, + type CreateOnlineLlmEvaluatorRequest as CreateOnlineLlmEvaluatorRequest, + type GetOnlineEvaluatorSpendResponse as GetOnlineEvaluatorSpendResponse, + type OnlineCodeEvaluator as OnlineCodeEvaluator, + type OnlineEvaluator as OnlineEvaluator, + type OnlineEvaluatorRunRule as OnlineEvaluatorRunRule, + type OnlineEvaluatorSpendDay as OnlineEvaluatorSpendDay, + type OnlineEvaluatorSpendGroup as OnlineEvaluatorSpendGroup, + type OnlineEvaluatorType as OnlineEvaluatorType, + type OnlineLlmEvaluator as OnlineLlmEvaluator, + type OnlineSpendLimit as OnlineSpendLimit, + type UpdateOnlineCodeEvaluatorRequest as UpdateOnlineCodeEvaluatorRequest, + type UpdateOnlineEvaluatorRequest as UpdateOnlineEvaluatorRequest, + type UpdateOnlineEvaluatorResponse as UpdateOnlineEvaluatorResponse, + type UpdateOnlineLlmEvaluatorRequest as UpdateOnlineLlmEvaluatorRequest, + type OnlineEvaluatorsOffsetPaginationOnlineEvaluators as OnlineEvaluatorsOffsetPaginationOnlineEvaluators, + type OnlineEvaluatorCreateParams as OnlineEvaluatorCreateParams, + type OnlineEvaluatorUpdateParams as OnlineEvaluatorUpdateParams, + type OnlineEvaluatorListParams as OnlineEvaluatorListParams, + type OnlineEvaluatorDeleteParams as OnlineEvaluatorDeleteParams, + type OnlineEvaluatorBulkDeleteParams as OnlineEvaluatorBulkDeleteParams, + type OnlineEvaluatorSpendParams as OnlineEvaluatorSpendParams, + }; +} diff --git a/src/resources/product-feedback.ts b/src/resources/product-feedback.ts new file mode 100644 index 0000000..7a4bbdd --- /dev/null +++ b/src/resources/product-feedback.ts @@ -0,0 +1,129 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import { APIPromise } from '../core/api-promise'; +import { buildHeaders } from '../internal/headers'; +import { RequestOptions } from '../internal/request-options'; +import { path } from '../internal/utils/path'; + +export class ProductFeedback extends APIResource { + /** + * **Alpha:** This endpoint is in active development and may change without notice. + * + * Submits concise product feedback with optional non-sensitive client details. + */ + create( + params: ProductFeedbackCreateParams, + options?: RequestOptions, + ): APIPromise { + const { 'Idempotency-Key': idempotencyKey, ...body } = params; + return this._client.post('/api/v1/platform/product-feedbacks', { + body, + ...options, + headers: buildHeaders([ + { ...(idempotencyKey != null ? { 'Idempotency-Key': idempotencyKey } : undefined) }, + options?.headers, + ]), + }); + } + + /** + * **Alpha:** This endpoint is in active development and may change without notice. + */ + retrieve(id: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v1/platform/product-feedbacks/${id}`, options); + } +} + +export interface ProductFeedbackCreateResponse { + id: string; + + category: 'BUG' | 'FEATURE_REQUEST' | 'USABILITY' | 'DOCUMENTATION' | 'OTHER'; + + created_at: string; + + message: string; + + source: 'LANGSMITH_CLI'; + + client?: ProductFeedbackCreateResponse.Client; +} + +export namespace ProductFeedbackCreateResponse { + export interface Client { + architecture?: string; + + os?: string; + + version?: string; + } +} + +export interface ProductFeedbackRetrieveResponse { + id: string; + + category: 'BUG' | 'FEATURE_REQUEST' | 'USABILITY' | 'DOCUMENTATION' | 'OTHER'; + + created_at: string; + + message: string; + + source: 'LANGSMITH_CLI'; + + client?: ProductFeedbackRetrieveResponse.Client; +} + +export namespace ProductFeedbackRetrieveResponse { + export interface Client { + architecture?: string; + + os?: string; + + version?: string; + } +} + +export interface ProductFeedbackCreateParams { + /** + * Body param + */ + category: 'BUG' | 'FEATURE_REQUEST' | 'USABILITY' | 'DOCUMENTATION' | 'OTHER'; + + /** + * Body param + */ + message: string; + + /** + * Body param + */ + source: 'LANGSMITH_CLI'; + + /** + * Body param + */ + client?: ProductFeedbackCreateParams.Client; + + /** + * Header param: Idempotency key + */ + 'Idempotency-Key'?: string; +} + +export namespace ProductFeedbackCreateParams { + export interface Client { + architecture?: string; + + os?: string; + + version?: string; + } +} + +export declare namespace ProductFeedback { + export { + type ProductFeedbackCreateResponse as ProductFeedbackCreateResponse, + type ProductFeedbackRetrieveResponse as ProductFeedbackRetrieveResponse, + type ProductFeedbackCreateParams as ProductFeedbackCreateParams, + }; +} diff --git a/src/resources/public.ts b/src/resources/public.ts new file mode 100644 index 0000000..f1cc8e3 --- /dev/null +++ b/src/resources/public.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './public/index'; diff --git a/src/resources/public/index.ts b/src/resources/public/index.ts new file mode 100644 index 0000000..5b624fe --- /dev/null +++ b/src/resources/public/index.ts @@ -0,0 +1,4 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { Public } from './public'; +export { Runs, type RunQueryResponse, type RunRetrieveParams, type RunQueryParams } from './runs'; diff --git a/src/resources/public/public.ts b/src/resources/public/public.ts new file mode 100644 index 0000000..e3d058b --- /dev/null +++ b/src/resources/public/public.ts @@ -0,0 +1,20 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as RunsAPI from './runs'; +import { RunQueryParams, RunQueryResponse, RunRetrieveParams, Runs } from './runs'; + +export class Public extends APIResource { + runs: RunsAPI.Runs = new RunsAPI.Runs(this._client); +} + +Public.Runs = Runs; + +export declare namespace Public { + export { + Runs as Runs, + type RunQueryResponse as RunQueryResponse, + type RunRetrieveParams as RunRetrieveParams, + type RunQueryParams as RunQueryParams, + }; +} diff --git a/src/resources/public/runs.ts b/src/resources/public/runs.ts new file mode 100644 index 0000000..9b08afc --- /dev/null +++ b/src/resources/public/runs.ts @@ -0,0 +1,147 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; +import * as RunsAPI_ from '../runs/runs'; + +export class Runs extends APIResource { + /** + * Returns one run within the trace identified by the share token. The request + * supplies only the run ID and that run's exact start_time coordinate. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const run = await client.public.runs.retrieve( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * { + * share_token: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * selects: ['string'], + * start_time: '2019-12-27T18:11:19.117Z', + * }, + * ); + * ``` + */ + retrieve(runID: string, params: RunRetrieveParams, options?: RequestOptions): APIPromise { + const { share_token, Accept, ...query } = params; + return this._client.get(path`/api/v2/public/${share_token}/run/${runID}`, { + query, + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } + + /** + * Returns all runs within the trace identified by the share token. The share token + * supplies the tenant, project, and trace scope. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const response = await client.public.runs.query( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * ); + * ``` + */ + query(shareToken: string, params: RunQueryParams, options?: RequestOptions): APIPromise { + const { Accept, ...body } = params; + return this._client.post(path`/api/v2/public/${shareToken}/runs/query`, { + body, + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } +} + +export interface RunQueryResponse { + /** + * `items` lists runs in the trace for the requested time window, in `start_time` + * order. + */ + items?: Array; +} + +export interface RunRetrieveParams { + /** + * Path param: Share token UUID + */ + share_token: string; + + /** + * Query param: repeatable public run fields to include + */ + selects: Array; + + /** + * Query param: Run start_time coordinate (RFC3339) + */ + start_time: string; + + /** + * Header param: application/json + */ + Accept?: string; +} + +export interface RunQueryParams { + /** + * Body param: `selects` lists which public run properties to include on each + * returned run. + */ + selects?: Array< + | 'ID' + | 'NAME' + | 'RUN_TYPE' + | 'STATUS' + | 'START_TIME' + | 'END_TIME' + | 'LATENCY_SECONDS' + | 'FIRST_TOKEN_TIME' + | 'ERROR' + | 'ERROR_PREVIEW' + | 'EXTRA' + | 'METADATA' + | 'INPUTS_PREVIEW' + | 'OUTPUTS_PREVIEW' + | 'PARENT_RUN_ID' + | 'PARENT_RUN_IDS' + | 'PROJECT_ID' + | 'TRACE_ID' + | 'THREAD_ID' + | 'DOTTED_ORDER' + | 'IS_ROOT' + | 'REFERENCE_DATASET_ID' + | 'TOTAL_TOKENS' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'TOTAL_COST' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'PRICE_MODEL_ID' + | 'TAGS' + | 'THREAD_EVALUATION_TIME' + | 'FEEDBACK_STATS' + >; + + /** + * Header param: application/json or text/event-stream + */ + Accept?: string; +} + +export declare namespace Runs { + export { + type RunQueryResponse as RunQueryResponse, + type RunRetrieveParams as RunRetrieveParams, + type RunQueryParams as RunQueryParams, + }; +} diff --git a/src/resources/runs.ts b/src/resources/runs.ts new file mode 100644 index 0000000..a3cc2bc --- /dev/null +++ b/src/resources/runs.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './runs/index'; diff --git a/src/resources/runs/index.ts b/src/resources/runs/index.ts new file mode 100644 index 0000000..40be47c --- /dev/null +++ b/src/resources/runs/index.ts @@ -0,0 +1,20 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + Runs, + type ResponseBodyForRunsGenerateQuery, + type Run, + type RunIngest, + type RunSchema, + type RunSelectField, + type RunStatsQueryParams, + type RunType, + type RunTypeEnum, + type RunsFilterDataSourceTypeEnum, + type RunGetURLResponse, + type RunGetURLParams, + type RunQueryV2Params, + type RunRetrieveV2Params, + type RunsItemsCursorPostPagination, +} from './runs'; +export { Share, type ShareCreateResponse, type ShareCreateParams, type ShareDeleteParams } from './share'; diff --git a/src/resources/runs/runs.ts b/src/resources/runs/runs.ts new file mode 100644 index 0000000..e4b28f3 --- /dev/null +++ b/src/resources/runs/runs.ts @@ -0,0 +1,1097 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as Shared from '../shared'; +import * as ShareAPI from './share'; +import { Share, ShareCreateParams, ShareCreateResponse, ShareDeleteParams } from './share'; +import { APIPromise } from '../../core/api-promise'; +import { + ItemsCursorPostPagination, + type ItemsCursorPostPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Runs extends APIResource { + share: ShareAPI.Share = new ShareAPI.Share(this._client); + + /** + * Returns the URL to view a specific run in the LangSmith UI. The caller must + * supply the run's project_id and trace_id as query parameters; start_time is + * optional. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const response = await client.runs.getURL( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * { project_id: 'project_id', trace_id: 'trace_id' }, + * ); + * ``` + */ + getURL(runID: string, query: RunGetURLParams, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/runs/${runID}/url`, { query, ...options }); + } + + /** + * Returns a paginated list of runs for the given projects within min/max + * start_time. Supports filters, cursor pagination, and `selects` to select fields + * to return. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const run of client.runs.queryV2()) { + * // ... + * } + * ``` + */ + queryV2( + params: RunQueryV2Params, + options?: RequestOptions, + ): PagePromise { + const { Accept, ...body } = params; + return this._client.getAPIList('/api/v2/runs/query', ItemsCursorPostPagination, { + body, + method: 'post', + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } + + /** + * Returns one run by ID for the given session. Use the `selects` query parameter + * (repeatable) to select fields to return. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const run = await client.runs.retrieveV2( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * { project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e' }, + * ); + * ``` + */ + retrieveV2(runID: string, params: RunRetrieveV2Params, options?: RequestOptions): APIPromise { + const { Accept, ...query } = params; + return this._client.get(path`/api/v2/runs/${runID}`, { + query, + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } + + retrieve = this.retrieveV2; + + query = this.queryV2; +} + +export type RunsItemsCursorPostPagination = ItemsCursorPostPagination; + +export interface ResponseBodyForRunsGenerateQuery { + feedback_urls: { [key: string]: string }; + + filter: string; +} + +export interface Run { + /** + * `id` is this run's UUID. + */ + id?: string; + + /** + * `app_path` identifies the application code location that produced this run, if + * recorded. + */ + app_path?: string; + + /** + * `attachments` maps each attachment file name to a pre-signed HTTPS download URL. + */ + attachments?: { [key: string]: string }; + + /** + * `completion_cost` is estimated USD cost for the completion. + */ + completion_cost?: number; + + /** + * `completion_cost_details` is the per-category USD breakdown of + * `completion_cost`. Categories mirror `completion_token_details`. Returned only + * when the `COMPLETION_COST_DETAILS` field is requested. + */ + completion_cost_details?: Run.CompletionCostDetails; + + /** + * `completion_token_details` is the per-category breakdown of `completion_tokens`. + * Category names are model-specific (for example `reasoning`, `audio`). Returned + * only when the `COMPLETION_TOKEN_DETAILS` field is requested. + */ + completion_token_details?: Run.CompletionTokenDetails; + + /** + * `completion_tokens` is the completion-side token count. + */ + completion_tokens?: number; + + /** + * `dotted_order` is the hierarchical ordering key for trace trees. + */ + dotted_order?: string; + + /** + * `end_time` is when the run ended (RFC3339 date-time). JSON null if the run has + * not finished yet. + */ + end_time?: string; + + /** + * `error` is the error message when `status` indicates failure. + */ + error?: string; + + /** + * `error_preview` is a truncated plain-text error snippet. + */ + error_preview?: string; + + /** + * `events` is the ordered list of run events (for example streaming tokens). + */ + events?: Array; + + /** + * `extra` is additional runtime JSON attached to the run. + */ + extra?: { [key: string]: unknown }; + + /** + * `feedback_stats` aggregates feedback scores keyed by feedback key. + */ + feedback_stats?: { [key: string]: Run.FeedbackStats }; + + /** + * `first_token_time` is when the first output token was produced (RFC3339 + * date-time), when recorded for streamed runs. + */ + first_token_time?: string; + + /** + * `inputs` is the run input payload (arbitrary JSON object). + */ + inputs?: { [key: string]: unknown }; + + /** + * `inputs_preview` is a truncated plain-text preview of inputs. + */ + inputs_preview?: string; + + /** + * `is_in_dataset` is true when this run is linked to a dataset example. + */ + is_in_dataset?: boolean; + + /** + * `is_root` is true when this run has no parent (it is the trace root). + */ + is_root?: boolean; + + /** + * `last_queued_at` is the most recent time this run was added to an annotation + * queue. + */ + last_queued_at?: string; + + /** + * `latency_seconds` is wall-clock duration from start to end in seconds. + */ + latency_seconds?: number; + + /** + * `ls_user_id` identifies the LangSmith user whose credential traced the run. It + * is absent for runs traced with a service-account API key, which has no + * associated user. + */ + ls_user_id?: string; + + /** + * `manifest` is the serialized configuration of the traced component (for example + * the model parameters, prompt template, or pipeline definition), when recorded. + */ + manifest?: { [key: string]: unknown }; + + /** + * `metadata` is arbitrary user-defined JSON metadata. + */ + metadata?: { [key: string]: unknown }; + + /** + * `name` is a human-readable label for the run (for example the model name, + * function name, or step name chosen when the run was traced). + */ + name?: string; + + /** + * `outputs` is the run output payload (arbitrary JSON object). + */ + outputs?: { [key: string]: unknown }; + + /** + * `outputs_preview` is a truncated plain-text preview of outputs. + */ + outputs_preview?: string; + + /** + * `parent_run_ids` lists ancestor run UUIDs from the trace root down to the direct + * parent. + */ + parent_run_ids?: Array; + + /** + * `price_model_id` identifies the pricing model UUID used for cost estimates, when + * recorded. + */ + price_model_id?: string; + + /** + * `project_id` is the tracing project UUID this run was logged to. + */ + project_id?: string; + + /** + * `prompt_cost` is estimated USD cost for the prompt. + */ + prompt_cost?: number; + + /** + * `prompt_cost_details` is the per-category USD breakdown of `prompt_cost`. + * Categories mirror `prompt_token_details`. Returned only when the + * `PROMPT_COST_DETAILS` field is requested. + */ + prompt_cost_details?: Run.PromptCostDetails; + + /** + * `prompt_token_details` is the per-category breakdown of `prompt_tokens`. + * Category names are model-specific (for example `cache_read`, `cache_write`). + * Returned only when the `PROMPT_TOKEN_DETAILS` field is requested. + */ + prompt_token_details?: Run.PromptTokenDetails; + + /** + * `prompt_tokens` is the prompt-side token count. + */ + prompt_tokens?: number; + + /** + * `reference_dataset_id` is the dataset UUID for the reference example, if any. + */ + reference_dataset_id?: string; + + /** + * `reference_example_id` is the dataset example UUID this run was compared + * against, if any. + */ + reference_example_id?: string; + + /** + * `run_type` identifies what kind of operation this run represents (for example an + * LLM call, a tool invocation, or a chain step). See the `RunType` enum for + * allowed values. + */ + run_type?: RunType; + + /** + * `share_url` is the fully-qualified URL of this run's public view, rooted at the + * deployment's LangSmith app origin (for example + * `https://smith.langchain.com/public/4f7a1b2c-8d9e-4a0b-9c1d-2e3f4a5b6c7d/r`). It + * is returned only when `SHARE_URL` is included in `selects`, and only when the + * run has been explicitly shared; the URL remains stable until the run is + * unshared. Anyone with this URL can view the run anonymously, so treat it as a + * secret and do not log it. + */ + share_url?: string; + + /** + * `start_time` is when the run started (RFC3339 date-time). + */ + start_time?: string; + + /** + * `status` is the completion status of the run. + */ + status?: 'SUCCESS' | 'ERROR' | 'PENDING'; + + /** + * `tags` lists user-defined tags on this run. + */ + tags?: Array; + + /** + * `thread_evaluation_time` is thread-level evaluation timing (RFC3339 date-time), + * when recorded. + */ + thread_evaluation_time?: string; + + /** + * `thread_id` is the conversation thread UUID this run belongs to, if any. + */ + thread_id?: string; + + /** + * `total_cost` is total estimated USD cost (prompt plus completion). + */ + total_cost?: number; + + /** + * `total_tokens` is prompt plus completion tokens. + */ + total_tokens?: number; + + /** + * `trace_id` is the root trace UUID; for a root run it matches `id`. + */ + trace_id?: string; +} + +export namespace Run { + /** + * `completion_cost_details` is the per-category USD breakdown of + * `completion_cost`. Categories mirror `completion_token_details`. Returned only + * when the `COMPLETION_COST_DETAILS` field is requested. + */ + export interface CompletionCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `completion_token_details` is the per-category breakdown of `completion_tokens`. + * Category names are model-specific (for example `reasoning`, `audio`). Returned + * only when the `COMPLETION_TOKEN_DETAILS` field is requested. + */ + export interface CompletionTokenDetails { + /** + * `raw` maps each category name to its completion-token count. + */ + raw?: { [key: string]: number }; + } + + export interface Event { + /** + * `kwargs` is the event payload — an opaque JSON object whose shape depends on + * `name` and on the emitting SDK. For example LangChain emits `{"token": {...}}` + * for `new_token` events, tool-call start/end details for tool events, and + * arbitrary user-defined payloads for custom events. Clients should treat `kwargs` + * as untyped JSON: do not assume specific keys exist for a given `name`, and + * tolerate additional unknown keys appearing over time. + */ + kwargs?: unknown; + + /** + * `name` is the event kind. Common values emitted by the LangChain/LangSmith + * tracer SDKs include `"start"`, `"end"`, and `"new_token"`, but applications may + * emit arbitrary strings for their own instrumentation. + */ + name?: string; + + /** + * `time` is when the event occurred (RFC3339 date-time with millisecond + * precision). + */ + time?: string; + } + + export interface FeedbackStats { + /** + * `avg` is the arithmetic mean of numeric feedback scores for this key on the run, + * or `null` when no numeric score has been recorded (for example purely + * categorical feedback). + */ + avg?: number; + + /** + * `comments` is a sample of human-readable comments attached to feedback points + * for this key, in no particular order. May be empty; is not exhaustive when many + * comments exist. + */ + comments?: Array; + + /** + * `contains_thread_feedback` is true when at least one feedback point for this key + * was submitted at the thread level (rather than at an individual run). Always + * false on responses that already describe a single run in isolation. + */ + contains_thread_feedback?: boolean; + + /** + * `errors` is the number of feedback points recorded as errors rather than + * successful scores (for example an automated evaluator that raised an exception). + * Defaults to 0 when no errors occurred. + */ + errors?: number; + + /** + * `max` is the largest numeric feedback score recorded for this key on the run, or + * `null` when no numeric score has been recorded. + */ + max?: number; + + /** + * `min` is the smallest numeric feedback score recorded for this key on the run, + * or `null` when no numeric score has been recorded. + */ + min?: number; + + /** + * `n` is the number of feedback points recorded for this key on the run. For + * numeric feedback this is the sample size behind `avg`, `min`, `max`, and + * `stdev`; for categorical feedback it is the sum of the `values` counts. + */ + n?: number; + + /** + * `sources` is a sample of feedback sources for this key. Each entry is either a + * plain string identifier (for example `"api"`, `"app"`, `"model"`) or a JSON + * object describing a synthetic source (for example + * `{"type": "__ls_composite_feedback"}` for a computed aggregate). Clients must + * tolerate both shapes. + */ + sources?: Array; + + /** + * `stdev` is the sample standard deviation of numeric feedback scores for this key + * on the run, or `null` when it cannot be computed (for example fewer than two + * numeric scores, or purely categorical feedback). + */ + stdev?: number; + + /** + * `values` is the distribution of categorical feedback labels for this key, + * mapping each label to its occurrence count. Empty (`{}`) for purely numeric + * feedback. + */ + values?: { [key: string]: number }; + } + + /** + * `prompt_cost_details` is the per-category USD breakdown of `prompt_cost`. + * Categories mirror `prompt_token_details`. Returned only when the + * `PROMPT_COST_DETAILS` field is requested. + */ + export interface PromptCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `prompt_token_details` is the per-category breakdown of `prompt_tokens`. + * Category names are model-specific (for example `cache_read`, `cache_write`). + * Returned only when the `PROMPT_TOKEN_DETAILS` field is requested. + */ + export interface PromptTokenDetails { + /** + * `raw` maps each category name to its prompt-token count. + */ + raw?: { [key: string]: number }; + } +} + +export interface RunIngest { + id?: string; + + /** + * Beta. Addresses the run to an Agent environment in place of session_id or + * session_name. Cannot be combined with agent_id or agent_environment. Only + * workspaces enabled for Agent addressing accept it; others get a 403. + */ + address?: Shared.AgentAddress; + + /** + * Beta. The Agent environment the run belongs to, case-insensitive; requires + * agent_id. Only workspaces enabled for Agent addressing accept it; others get + * a 403. + */ + agent_environment?: 'LOCAL' | 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION'; + + /** + * Beta, superseded by address. Addresses the run to an Agent, with + * agent_environment, in place of session_id or session_name. Only workspaces + * enabled for Agent addressing accept it; others get a 403. + */ + agent_id?: string; + + dotted_order?: string; + + end_time?: string; + + error?: string; + + events?: Array<{ [key: string]: unknown }>; + + extra?: { [key: string]: unknown }; + + input_attachments?: { [key: string]: unknown }; + + inputs?: { [key: string]: unknown }; + + name?: string; + + output_attachments?: { [key: string]: unknown }; + + outputs?: { [key: string]: unknown }; + + parent_run_id?: string; + + reference_example_id?: string; + + run_type?: 'tool' | 'chain' | 'llm' | 'retriever' | 'embedding' | 'prompt' | 'parser'; + + serialized?: { [key: string]: unknown }; + + session_id?: string; + + session_name?: string; + + start_time?: string; + + status?: string; + + tags?: Array; + + trace_id?: string; +} + +/** + * Run schema. + */ +export interface RunSchema { + id: string; + + app_path: string; + + dotted_order: string; + + name: string; + + /** + * Enum for run types. + */ + run_type: RunTypeEnum; + + session_id: string; + + status: string; + + trace_id: string; + + child_run_ids?: Array | null; + + completion_cost?: string | null; + + completion_cost_details?: { [key: string]: string } | null; + + completion_token_details?: { [key: string]: number } | null; + + completion_tokens?: number; + + direct_child_run_ids?: Array | null; + + end_time?: string | null; + + error?: string | null; + + events?: Array<{ [key: string]: unknown }> | null; + + execution_order?: number; + + extra?: { [key: string]: unknown } | null; + + feedback_stats?: { [key: string]: { [key: string]: unknown } } | null; + + first_token_time?: string | null; + + in_dataset?: boolean | null; + + inputs?: { [key: string]: unknown } | null; + + inputs_preview?: string | null; + + inputs_s3_urls?: { [key: string]: unknown } | null; + + last_queued_at?: string | null; + + manifest_id?: string | null; + + manifest_s3_id?: string | null; + + messages?: Array<{ [key: string]: unknown }> | null; + + outputs?: { [key: string]: unknown } | null; + + outputs_preview?: string | null; + + outputs_s3_urls?: { [key: string]: unknown } | null; + + parent_run_id?: string | null; + + parent_run_ids?: Array | null; + + price_model_id?: string | null; + + prompt_cost?: string | null; + + prompt_cost_details?: { [key: string]: string } | null; + + prompt_token_details?: { [key: string]: number } | null; + + prompt_tokens?: number; + + reference_dataset_id?: string | null; + + reference_example_id?: string | null; + + s3_urls?: { [key: string]: unknown } | null; + + serialized?: { [key: string]: unknown } | null; + + share_token?: string | null; + + start_time?: string; + + tags?: Array | null; + + thread_id?: string | null; + + total_cost?: string | null; + + total_tokens?: number; + + trace_first_received_at?: string | null; + + trace_max_start_time?: string | null; + + trace_min_start_time?: string | null; + + trace_tier?: 'longlived' | 'shortlived' | null; + + trace_upgrade?: boolean; + + ttl_seconds?: number | null; +} + +export type RunSelectField = + | 'ID' + | 'NAME' + | 'RUN_TYPE' + | 'STATUS' + | 'START_TIME' + | 'END_TIME' + | 'LATENCY_SECONDS' + | 'FIRST_TOKEN_TIME' + | 'ERROR' + | 'ERROR_PREVIEW' + | 'EXTRA' + | 'METADATA' + | 'EVENTS' + | 'INPUTS' + | 'INPUTS_PREVIEW' + | 'OUTPUTS' + | 'OUTPUTS_PREVIEW' + | 'MANIFEST' + | 'PARENT_RUN_IDS' + | 'PROJECT_ID' + | 'TRACE_ID' + | 'THREAD_ID' + | 'DOTTED_ORDER' + | 'IS_ROOT' + | 'REFERENCE_EXAMPLE_ID' + | 'REFERENCE_DATASET_ID' + | 'TOTAL_TOKENS' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'TOTAL_COST' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'PRICE_MODEL_ID' + | 'TAGS' + | 'APP_PATH' + | 'ATTACHMENTS' + | 'THREAD_EVALUATION_TIME' + | 'IS_IN_DATASET' + | 'LAST_QUEUED_AT' + | 'SHARE_URL' + | 'FEEDBACK_STATS' + | 'LS_USER_ID'; + +/** + * Query params for run stats. + */ +export interface RunStatsQueryParams { + session: Array; + + id?: Array | null; + + /** + * Enum for run data source types. + */ + data_source_type?: RunsFilterDataSourceTypeEnum | null; + + end_time?: string | null; + + error?: boolean | null; + + execution_order?: number | null; + + filter?: string | null; + + /** + * Group by param for run stats. + */ + group_by?: RunStatsQueryParams.GroupBy | null; + + groups?: Array | null; + + include_details?: boolean; + + is_root?: boolean | null; + + parent_run?: string | null; + + query?: string | null; + + reference_dataset_id?: string | null; + + reference_example?: Array | null; + + /** + * Enum for run types. + */ + run_type?: RunTypeEnum | null; + + search_filter?: string | null; + + select?: Array< + | 'run_count' + | 'latency_p50' + | 'latency_p99' + | 'latency_avg' + | 'first_token_p50' + | 'first_token_p99' + | 'total_tokens' + | 'prompt_tokens' + | 'completion_tokens' + | 'median_tokens' + | 'completion_tokens_p50' + | 'prompt_tokens_p50' + | 'tokens_p99' + | 'completion_tokens_p99' + | 'prompt_tokens_p99' + | 'last_run_start_time' + | 'feedback_stats' + | 'thread_feedback_stats' + | 'run_facets' + | 'error_rate' + | 'streaming_rate' + | 'total_cost' + | 'prompt_cost' + | 'completion_cost' + | 'cost_p50' + | 'cost_p99' + | 'session_feedback_stats' + | 'all_run_stats' + | 'all_token_stats' + | 'group_count' + | 'prompt_token_details' + | 'completion_token_details' + | 'prompt_cost_details' + | 'completion_cost_details' + > | null; + + skip_pagination?: boolean | null; + + start_time?: string | null; + + /** + * Filter runs by trace ID. When set, limit and cursor-based pagination are not + * applied — all runs in the trace are returned in a single response. + */ + trace?: string | null; + + trace_filter?: string | null; + + tree_filter?: string | null; + + use_experimental_search?: boolean; +} + +export namespace RunStatsQueryParams { + /** + * Group by param for run stats. + */ + export interface GroupBy { + attribute: 'name' | 'run_type' | 'tag' | 'metadata'; + + max_groups?: number; + + path?: string | null; + } +} + +export type RunType = 'TOOL' | 'CHAIN' | 'LLM' | 'RETRIEVER' | 'EMBEDDING' | 'PROMPT' | 'PARSER'; + +/** + * Enum for run types. + */ +export type RunTypeEnum = 'tool' | 'chain' | 'llm' | 'retriever' | 'embedding' | 'prompt' | 'parser'; + +/** + * Enum for run data source types. + */ +export type RunsFilterDataSourceTypeEnum = + | 'current' + | 'historical' + | 'lite' + | 'root_lite' + | 'runs_feedbacks_rmt_wide'; + +export interface RunGetURLResponse { + url?: string; +} + +export interface RunGetURLParams { + /** + * Project (session) UUID + */ + project_id: string; + + /** + * Trace UUID + */ + trace_id: string; + + /** + * Run start time in RFC3339 format; omit if unknown + */ + start_time?: string; +} + +export interface RunQueryV2Params extends ItemsCursorPostPaginationParams { + /** + * Body param: `filter` narrows results to runs matching this LangSmith filter + * expression, evaluated against each individual run. For example: and(eq(run_type, + * "llm"), gt(latency, 5)) or eq(status, "error"). See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + filter?: string; + + /** + * Body param: `has_error` filters to runs that errored (true) or completed without + * error (false). + */ + has_error?: boolean; + + /** + * Body param: `ids` optionally limits the request to these run UUIDs. + */ + ids?: Array; + + /** + * Body param: `is_root` returns only root runs (true) or only non-root runs + * (false). + */ + is_root?: boolean; + + /** + * Body param: `max_start_time` is the upper bound for run `start_time` (RFC3339). + * Defaults to now. + */ + max_start_time?: string; + + /** + * Body param: `min_start_time` is the lower bound for run `start_time` (RFC3339). + * Defaults to 1 day ago. + */ + min_start_time?: string; + + /** + * Body param: `project_ids` lists tracing project UUIDs to query. Required unless + * `reference_dataset_id` is set. Mutually exclusive with `reference_dataset_id` — + * set exactly one of them. + */ + project_ids?: Array; + + /** + * Body param: `reference_dataset_id` resolves session IDs server-side from the + * dataset. Required unless `project_ids` is set. Mutually exclusive with + * `project_ids` — set exactly one of them. When provided and `min_start_time` is + * omitted, the server derives it from the earliest session creation date. + */ + reference_dataset_id?: string; + + /** + * Body param: `reference_examples` optionally limits to runs linked to these + * dataset example UUIDs. + */ + reference_examples?: Array; + + /** + * Body param: `run_type`, when set, restricts results to runs whose `run_type` + * equals this value. + */ + run_type?: RunType; + + /** + * Body param: `selects` lists which properties to include on each returned run. If + * omitted, only `id` is returned. Properties not listed are omitted from each run + * object. + */ + selects?: Array; + + /** + * Body param: `trace_filter` narrows results to runs whose root trace matches this + * LangSmith filter expression. Use this to filter by properties of the trace's + * root run — for example eq(status, "success") to include only traces that + * completed without error. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + trace_filter?: string; + + /** + * Body param: `trace_id` optionally limits results to runs belonging to this trace + * UUID. + */ + trace_id?: string; + + /** + * Body param: `tree_filter` narrows results to runs that belong to a trace + * containing at least one run matching this LangSmith filter expression anywhere + * in the run tree (not just the root). Use this to find runs inside traces that + * involved a specific tool, tag, or model — for example has(tags, "production") or + * eq(name, "my_tool"). See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + tree_filter?: string; + + /** + * Header param: application/json or text/event-stream + */ + Accept?: string; +} + +export interface RunRetrieveV2Params { + /** + * Query param: `project_id` is the UUID of the tracing project that owns the run. + */ + project_id: string; + + /** + * Query param: `selects` lists which properties to include on the returned run + * (repeatable query parameter). Accepts any value of the `RunSelectField` enum. If + * omitted, only `id` is returned. + */ + selects?: Array< + | 'ID' + | 'NAME' + | 'RUN_TYPE' + | 'STATUS' + | 'START_TIME' + | 'END_TIME' + | 'LATENCY_SECONDS' + | 'FIRST_TOKEN_TIME' + | 'ERROR' + | 'ERROR_PREVIEW' + | 'EXTRA' + | 'METADATA' + | 'EVENTS' + | 'INPUTS' + | 'INPUTS_PREVIEW' + | 'OUTPUTS' + | 'OUTPUTS_PREVIEW' + | 'MANIFEST' + | 'PARENT_RUN_IDS' + | 'PROJECT_ID' + | 'TRACE_ID' + | 'THREAD_ID' + | 'DOTTED_ORDER' + | 'IS_ROOT' + | 'REFERENCE_EXAMPLE_ID' + | 'REFERENCE_DATASET_ID' + | 'TOTAL_TOKENS' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'TOTAL_COST' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'PRICE_MODEL_ID' + | 'TAGS' + | 'APP_PATH' + | 'ATTACHMENTS' + | 'THREAD_EVALUATION_TIME' + | 'IS_IN_DATASET' + | 'LAST_QUEUED_AT' + | 'SHARE_URL' + | 'FEEDBACK_STATS' + | 'LS_USER_ID' + >; + + /** + * Query param: `start_time` is the run's `start_time` (RFC3339 date-time). + * Providing it speeds up retrieval. + */ + start_time?: string; + + /** + * Header param: application/json + */ + Accept?: string; +} + +Runs.Share = Share; + +export declare namespace Runs { + export { + type ResponseBodyForRunsGenerateQuery as ResponseBodyForRunsGenerateQuery, + type Run as Run, + type RunIngest as RunIngest, + type RunSchema as RunSchema, + type RunSelectField as RunSelectField, + type RunStatsQueryParams as RunStatsQueryParams, + type RunType as RunType, + type RunTypeEnum as RunTypeEnum, + type RunsFilterDataSourceTypeEnum as RunsFilterDataSourceTypeEnum, + type RunGetURLResponse as RunGetURLResponse, + type RunsItemsCursorPostPagination as RunsItemsCursorPostPagination, + type RunGetURLParams as RunGetURLParams, + type RunQueryV2Params as RunQueryV2Params, + type RunRetrieveV2Params as RunRetrieveV2Params, + }; + + export { + Share as Share, + type ShareCreateResponse as ShareCreateResponse, + type ShareCreateParams as ShareCreateParams, + type ShareDeleteParams as ShareDeleteParams, + }; +} diff --git a/src/resources/runs/share.ts b/src/resources/runs/share.ts new file mode 100644 index 0000000..92650e1 --- /dev/null +++ b/src/resources/runs/share.ts @@ -0,0 +1,77 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Share extends APIResource { + /** + * Creates or returns a share token for a run. Child runs share their trace root. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const share = await client.runs.share.create( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * ); + * ``` + */ + create(runID: string, body: ShareCreateParams, options?: RequestOptions): APIPromise { + return this._client.post(path`/api/v2/runs/${runID}/share`, { body, ...options }); + } + + /** + * Deletes the share token for the trace identified by trace_id and session_id. + * Idempotent: returns 204 whether or not a share token existed. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * await client.runs.share.delete( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * ); + * ``` + */ + delete(traceID: string, body: ShareDeleteParams, options?: RequestOptions): APIPromise { + return this._client.delete(path`/api/v2/runs/${traceID}/share`, { + body, + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } +} + +export interface ShareCreateResponse { + share_token?: string; +} + +export interface ShareCreateParams { + /** + * session_id is the tracing project UUID containing the trace. + */ + session_id?: string; + + /** + * trace_id is the root trace UUID to share. + */ + trace_id?: string; +} + +export interface ShareDeleteParams { + /** + * session_id is the tracing project UUID containing the trace. + */ + session_id?: string; +} + +export declare namespace Share { + export { + type ShareCreateResponse as ShareCreateResponse, + type ShareCreateParams as ShareCreateParams, + type ShareDeleteParams as ShareDeleteParams, + }; +} diff --git a/src/resources/sandboxes.ts b/src/resources/sandboxes.ts new file mode 100644 index 0000000..23c49c7 --- /dev/null +++ b/src/resources/sandboxes.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './sandboxes/index'; diff --git a/src/resources/sandboxes/boxes.ts b/src/resources/sandboxes/boxes.ts new file mode 100644 index 0000000..80cca6d --- /dev/null +++ b/src/resources/sandboxes/boxes.ts @@ -0,0 +1,1290 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as SandboxesAPI from './sandboxes'; +import { SandboxResponsesItemsCursorGetPagination } from './sandboxes'; +import { APIPromise } from '../../core/api-promise'; +import { + ItemsCursorGetPagination, + type ItemsCursorGetPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Boxes extends APIResource { + /** + * Create a new sandbox from a snapshot. Provide at most one of `snapshot_id` or + * `snapshot_name`; if neither is provided, the server uses the default snapshot. + * `snapshot_name` accepts a Docker-style `name` or `name:tag` reference (a bare + * name resolves to `name:latest`). + */ + create(body: BoxCreateParams, options?: RequestOptions): APIPromise { + return this._client.post('/api/v2/sandboxes/boxes', { body, ...options }); + } + + /** + * Retrieve a sandbox by name. Stale provisioning sandboxes are auto-failed. + */ + retrieve(name: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/sandboxes/boxes/${name}`, options); + } + + /** + * Update a sandbox's display name, retention, resources, tags, or proxy + * configuration. The name must be unique within the tenant. Proxy configuration + * sent to a sandbox that is not running is stored and applied when it next starts. + */ + update( + name: string, + body: BoxUpdateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.patch(path`/api/v2/sandboxes/boxes/${name}`, { body, ...options }); + } + + /** + * List sandboxes for the authenticated tenant, with optional filtering, sorting, + * and pagination. Page with page_size and cursor: replay the response's + * next_cursor until it comes back null, which is the only signal that no pages + * remain. Cursors are opaque and only valid on this endpoint; do not parse or + * construct one. + */ + list( + query: BoxListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + '/api/v2/sandboxes/boxes', + ItemsCursorGetPagination, + { query, ...options }, + ); + } + + /** + * Delete a sandbox by name or UUID. Tears down the sandbox runtime and removes the + * DB record. + */ + delete(name: string, options?: RequestOptions): APIPromise { + return this._client.delete(path`/api/v2/sandboxes/boxes/${name}`, { + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } + + /** + * Create a snapshot by capturing the current state of a sandbox or promoting an + * existing checkpoint. + */ + createSnapshot( + name: string, + body: BoxCreateSnapshotParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v2/sandboxes/boxes/${name}/snapshot`, { body, ...options }); + } + + /** + * Removes the sharing grant for one port, or for every port when port is omitted. + * A LangSmith login URL stops working immediately. A previously minted service + * token is not revoked and stays valid until it expires, but no new one can be + * issued from the removed grant. + */ + deleteServiceURL( + name: string, + params: BoxDeleteServiceURLParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + const { port } = params ?? {}; + return this._client.delete(path`/api/v2/sandboxes/boxes/${name}/service-urls`, { + query: { port }, + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } + + /** + * Generate a tokenized link that downloads a single file from a sandbox with no + * further authentication. This mints a token rather than creating an addressable + * resource, so it returns 200 with no Location header. The token pins the sandbox, + * the file path, the response content type and disposition, and the sandbox flags, + * so a link cannot be repointed at another file or served under a weaker policy. + * The file is always served with a Content-Security-Policy: a sandbox directive, + * plus a default-src holding every fetch to the file's own download host and a set + * of pre-approved third-party origins. csp_sandbox_flags may loosen the sandbox + * with allow-downloads, allow-forms, allow-modals, allow-orientation-lock, + * allow-pointer-lock, allow-popups, allow-presentation, allow-same-origin, + * allow-scripts, or allow-top-navigation-by-user-activation. Every file is served + * from its own host, derived from the sandbox and the path, so allow-same-origin + * gives a page localStorage and IndexedDB that no other file can read, and + * re-minting a link for the same file keeps them. csp_sandbox set to false drops + * the sandbox directive altogether, and csp_sandbox_flags must then be omitted. + * csp_source_bundles selects the third-party origins: cdnjs, google-fonts, + * jsdelivr, and unpkg are all allowed when the field is omitted, 'none' holds the + * file to its own host, and 'any' sends no default-src at all. Links never expire + * unless expires_in_seconds is set. The link is served from the sandbox service + * domain, not the API host. + */ + generateDownloadURL( + name: string, + body: BoxGenerateDownloadURLParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v2/sandboxes/boxes/${name}/download-url`, { body, ...options }); + } + + /** + * Create a short-lived JWT for accessing an HTTP service running on a specific + * port inside a sandbox. Returns a browser_url (sets auth cookie via redirect), a + * service_url (for use with the X-Langsmith-Sandbox-Service-Token header), the raw + * token, and its expiry. Set access=restricted|workspace to instead enable durable + * LangSmith login (no token; users authenticate with their normal LangSmith + * session), or access=off to disable it. LangSmith login and token access are + * mutually exclusive per service URL. + */ + generateServiceURL( + name: string, + body: BoxGenerateServiceURLParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v2/sandboxes/boxes/${name}/service-url`, { body, ...options }); + } + + /** + * Retrieve the lightweight status of a sandbox for polling. + */ + getStatus(name: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/sandboxes/boxes/${name}/status`, options); + } + + /** + * Returns one entry per port the sandbox is currently reachable on, so a caller + * can see what is shared before turning it off. Expired token grants are omitted. + * Cursors are opaque and only valid on this endpoint; do not parse or construct + * one. + */ + listServiceURLs( + name: string, + query: BoxListServiceURLsParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + path`/api/v2/sandboxes/boxes/${name}/service-urls`, + ItemsCursorGetPagination, + { query, ...options }, + ); + } + + /** + * Start a stopped or failed sandbox. This endpoint is not idempotent. + */ + start(name: string, options?: RequestOptions): APIPromise { + return this._client.post(path`/api/v2/sandboxes/boxes/${name}/start`, options); + } + + /** + * Stop a ready sandbox. This endpoint is not idempotent; the filesystem is + * preserved for later restart. + */ + stop(name: string, options?: RequestOptions): APIPromise { + return this._client.post(path`/api/v2/sandboxes/boxes/${name}/stop`, { + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } +} + +export type BoxListServiceURLsResponsesItemsCursorGetPagination = + ItemsCursorGetPagination; + +export interface BoxListServiceURLsResponse { + /** + * How the port is shared: "token" for a minted service token, or + * "restricted"/"workspace" for LangSmith login. + */ + access: 'token' | 'restricted' | 'workspace'; + + created_at: string; + + port: number; + + /** + * The LangSmith user who first shared this port, when known. + */ + created_by?: string; + + /** + * When the share expires. Set only for "token"; a login grant does not expire. + */ + expires_at?: string; +} + +export interface BoxCreateParams { + /** + * AccessDelegation lets code inside the sandbox call the LangSmith API as you, + * with at most the permissions granted here. Omit for no access. + */ + access_delegation?: BoxCreateParams.AccessDelegation; + + /** + * CPUMillicores optionally requests CPU at millicore granularity (e.g. 500 = 0.5 + * vCPU); takes precedence over VCPUs. Fractional (sub-vCPU) values are not + * available for every sandbox. + */ + cpu_millicores?: number; + + delete_after_stop_seconds?: number; + + env_vars?: { [key: string]: string }; + + fs_capacity_bytes?: number; + + idle_ttl_seconds?: number; + + /** + * Labels are free-form key/value metadata persisted with the sandbox and returned + * on reads. Labels from the source snapshot are inherited unless overridden here. + */ + labels?: { [key: string]: string }; + + /** + * Memory for the sandbox, in bytes. Memory is tied to CPU at 4 GiB per vCPU: omit + * it and it follows that ratio; set it and it must stay within 50% of the ratio + * for the requested CPU, so a 1 vCPU sandbox accepts 2-6 GiB. Setting memory + * without CPU derives the CPU from the same ratio. Maximum 64 GiB. + */ + mem_bytes?: number; + + mount_config?: BoxCreateParams.MountConfig; + + name?: string; + + /** + * PreserveMemoryOnStop, when true, suspends the sandbox's memory on a voluntary + * stop (idle timeout or explicit stop) so the next start resumes from where it + * left off. Default false discards memory and keeps only the filesystem, so the + * next start is a cold boot. Restarts triggered by infrastructure maintenance + * always preserve memory regardless of this setting. + */ + preserve_memory_on_stop?: boolean; + + proxy_config?: BoxCreateParams.ProxyConfig; + + /** + * RestoreMemory selects how the sandbox handles a snapshot's captured memory: + * + * nil → if-present: resume from memory when the snapshot has it, else cold-boot + * (default). true → always: resume from memory; rejected if the snapshot has none. + * false → never: always cold-boot. + * + * Applies to this request only. + */ + restore_memory?: boolean; + + /** + * RunConfig overrides the snapshot's run config for this sandbox: user and + * work_dir replace the snapshot's, env_vars merge over it. The result is what the + * sandbox boots with, and what a snapshot captured from it carries. + */ + run_config?: BoxCreateParams.RunConfig; + + /** + * Snapshot is a Docker-style name or name:tag reference to boot from. A bare name + * resolves to name:latest. + */ + snapshot?: string; + + snapshot_id?: string; + + /** + * SnapshotName is a synonym for Snapshot, accepted for compatibility with clients + * that predate it. Set one or the other. + */ + snapshot_name?: string; + + tag_value_ids?: Array; + + vcpus?: number; +} + +export namespace BoxCreateParams { + /** + * AccessDelegation lets code inside the sandbox call the LangSmith API as you, + * with at most the permissions granted here. Omit for no access. + */ + export interface AccessDelegation { + mode: 'INHERIT' | 'EXPLICIT'; + + permissions?: Array; + } + + export interface MountConfig { + auth?: MountConfig.Auth; + + mounts?: Array< + | MountConfig.SandboxapiS3BucketMountSpec + | MountConfig.SandboxapiGcsBucketMountSpec + | MountConfig.SandboxapiGitRepoMountSpec + | MountConfig.SandboxapiContextHubRepoMountSpec + >; + } + + export namespace MountConfig { + export interface Auth { + aws?: Auth.SandboxesSandboxAwsMountRoleAuthConfig | Auth.SandboxesSandboxAwsMountStaticAuthConfig; + + gcp?: Auth.Gcp; + } + + export namespace Auth { + export interface SandboxesSandboxAwsMountRoleAuthConfig { + /** + * IAM role to assume with permissions scoped to the configured S3 mounts. Mutually + * exclusive with static credentials. Configure only at creation. + */ + role_arn: string; + } + + export interface SandboxesSandboxAwsMountStaticAuthConfig { + access_key_id: SandboxesSandboxAwsMountStaticAuthConfig.AccessKeyID; + + secret_access_key: SandboxesSandboxAwsMountStaticAuthConfig.SecretAccessKey; + + /** + * IAM role to assume with permissions scoped to the configured S3 mounts. Mutually + * exclusive with static credentials. Configure only at creation. + */ + role_arn?: ''; + } + + export namespace SandboxesSandboxAwsMountStaticAuthConfig { + export interface AccessKeyID { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + + export interface SecretAccessKey { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Gcp { + service_account_json: Gcp.ServiceAccountJson; + } + + export namespace Gcp { + export interface ServiceAccountJson { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + } + + export interface SandboxapiS3BucketMountSpec { + id: string; + + mount_path: string; + + s3: SandboxapiS3BucketMountSpec.S3; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiS3BucketMountSpec.Cache; + + contexthub?: SandboxapiS3BucketMountSpec.Contexthub; + + gcs?: SandboxapiS3BucketMountSpec.Gcs; + + git?: SandboxapiS3BucketMountSpec.Git; + + read_only?: boolean; + } + + export namespace SandboxapiS3BucketMountSpec { + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + } + + export interface SandboxapiGcsBucketMountSpec { + id: string; + + gcs: SandboxapiGcsBucketMountSpec.Gcs; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiGcsBucketMountSpec.Cache; + + contexthub?: SandboxapiGcsBucketMountSpec.Contexthub; + + git?: SandboxapiGcsBucketMountSpec.Git; + + read_only?: boolean; + + s3?: SandboxapiGcsBucketMountSpec.S3; + } + + export namespace SandboxapiGcsBucketMountSpec { + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + + export interface SandboxapiGitRepoMountSpec { + id: string; + + git: SandboxapiGitRepoMountSpec.Git; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiGitRepoMountSpec.Cache; + + contexthub?: SandboxapiGitRepoMountSpec.Contexthub; + + gcs?: SandboxapiGitRepoMountSpec.Gcs; + + read_only?: boolean; + + s3?: SandboxapiGitRepoMountSpec.S3; + } + + export namespace SandboxapiGitRepoMountSpec { + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + + export interface SandboxapiContextHubRepoMountSpec { + id: string; + + contexthub: SandboxapiContextHubRepoMountSpec.Contexthub; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiContextHubRepoMountSpec.Cache; + + gcs?: SandboxapiContextHubRepoMountSpec.Gcs; + + git?: SandboxapiContextHubRepoMountSpec.Git; + + read_only?: boolean; + + s3?: SandboxapiContextHubRepoMountSpec.S3; + } + + export namespace SandboxapiContextHubRepoMountSpec { + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + } + + export interface ProxyConfig { + access_control?: ProxyConfig.AccessControl; + + callbacks?: Array; + + /** + * Description says what this configuration as a whole lets the sandbox reach, + * complementing the per-rule descriptions. At most 1024 characters. + */ + description?: string; + + no_proxy?: Array; + + rules?: Array; + } + + export namespace ProxyConfig { + export interface AccessControl { + allow_list?: Array; + + deny_list?: Array; + } + + export interface Callback { + match_hosts: Array; + + ttl_seconds: number; + + url: string; + + full_request?: boolean; + + request_headers?: Array; + } + + export namespace Callback { + export interface RequestHeader { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Rule { + name: string; + + aws?: Rule.SandboxesProxyAwsRoleConfig | Rule.SandboxesProxyAwsStaticConfig; + + /** + * Description says what this rule lets the sandbox reach, so an agent driving the + * sandbox can be told its capabilities. At most 1024 characters. + */ + description?: string; + + enabled?: boolean; + + /** + * EnvVars are plaintext env vars set for every command in the sandbox while this + * rule is enabled. Use them for tools that refuse to run unless a credential env + * var is present (e.g. gh needs GH_TOKEN) even though this rule injects the real + * credential on the wire — set a dummy value here so the command starts. Explicit + * per-sandbox env_vars win over these, and provider-managed (AWS/GCP) vars win + * over both. + */ + env_vars?: { [key: string]: string }; + + gcp?: Rule.Gcp; + + headers?: Array; + + /** + * MatchHeaders restricts a header injection rule to requests carrying every listed + * header, each written "name: value" with a lowercase name and an exact value. + * Pair with headers of the same name to swap a placeholder the sandbox sends (e.g. + * "authorization: Bearer account-b") for a real credential, so one host can serve + * several accounts. Rules are evaluated in order and the first match wins. + */ + match_headers?: Array; + + /** + * MatchHosts is only accepted for header injection rules. Provider auth rules use + * built-in host matching. + */ + match_hosts?: Array; + + match_paths?: Array; + + type?: string; + } + + export namespace Rule { + export interface SandboxesProxyAwsRoleConfig { + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn: string; + } + + export interface SandboxesProxyAwsStaticConfig { + access_key_id: SandboxesProxyAwsStaticConfig.AccessKeyID; + + secret_access_key: SandboxesProxyAwsStaticConfig.SecretAccessKey; + + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn?: ''; + } + + export namespace SandboxesProxyAwsStaticConfig { + export interface AccessKeyID { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + + export interface SecretAccessKey { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Gcp { + scopes: Array; + + service_account_json: Gcp.ServiceAccountJson; + } + + export namespace Gcp { + export interface ServiceAccountJson { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Header { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + } + + /** + * RunConfig overrides the snapshot's run config for this sandbox: user and + * work_dir replace the snapshot's, env_vars merge over it. The result is what the + * sandbox boots with, and what a snapshot captured from it carries. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface BoxUpdateParams { + cpu_millicores?: number; + + delete_after_stop_seconds?: number; + + fs_capacity_bytes?: number; + + idle_ttl_seconds?: number; + + /** + * New memory for the sandbox, in bytes. The 4 GiB per vCPU ratio applies when the + * sandbox is created; a resize enforces only the maximum of 64 GiB. + */ + mem_bytes?: number; + + name?: string; + + proxy_config?: BoxUpdateParams.ProxyConfig; + + /** + * RunConfig changes what subsequent commands run with: user and work_dir replace + * the current values, env_vars merge over them. Commands already running are + * unaffected. + */ + run_config?: BoxUpdateParams.RunConfig; + + tag_value_ids?: Array; + + vcpus?: number; +} + +export namespace BoxUpdateParams { + export interface ProxyConfig { + access_control?: ProxyConfig.AccessControl; + + callbacks?: Array; + + /** + * Description says what this configuration as a whole lets the sandbox reach, + * complementing the per-rule descriptions. At most 1024 characters. + */ + description?: string; + + no_proxy?: Array; + + rules?: Array; + } + + export namespace ProxyConfig { + export interface AccessControl { + allow_list?: Array; + + deny_list?: Array; + } + + export interface Callback { + match_hosts: Array; + + ttl_seconds: number; + + url: string; + + full_request?: boolean; + + request_headers?: Array; + } + + export namespace Callback { + export interface RequestHeader { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Rule { + name: string; + + aws?: Rule.SandboxesProxyAwsRoleConfig | Rule.SandboxesProxyAwsStaticConfig; + + /** + * Description says what this rule lets the sandbox reach, so an agent driving the + * sandbox can be told its capabilities. At most 1024 characters. + */ + description?: string; + + enabled?: boolean; + + /** + * EnvVars are plaintext env vars set for every command in the sandbox while this + * rule is enabled. Use them for tools that refuse to run unless a credential env + * var is present (e.g. gh needs GH_TOKEN) even though this rule injects the real + * credential on the wire — set a dummy value here so the command starts. Explicit + * per-sandbox env_vars win over these, and provider-managed (AWS/GCP) vars win + * over both. + */ + env_vars?: { [key: string]: string }; + + gcp?: Rule.Gcp; + + headers?: Array; + + /** + * MatchHeaders restricts a header injection rule to requests carrying every listed + * header, each written "name: value" with a lowercase name and an exact value. + * Pair with headers of the same name to swap a placeholder the sandbox sends (e.g. + * "authorization: Bearer account-b") for a real credential, so one host can serve + * several accounts. Rules are evaluated in order and the first match wins. + */ + match_headers?: Array; + + /** + * MatchHosts is only accepted for header injection rules. Provider auth rules use + * built-in host matching. + */ + match_hosts?: Array; + + match_paths?: Array; + + type?: string; + } + + export namespace Rule { + export interface SandboxesProxyAwsRoleConfig { + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn: string; + } + + export interface SandboxesProxyAwsStaticConfig { + access_key_id: SandboxesProxyAwsStaticConfig.AccessKeyID; + + secret_access_key: SandboxesProxyAwsStaticConfig.SecretAccessKey; + + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn?: ''; + } + + export namespace SandboxesProxyAwsStaticConfig { + export interface AccessKeyID { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + + export interface SecretAccessKey { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Gcp { + scopes: Array; + + service_account_json: Gcp.ServiceAccountJson; + } + + export namespace Gcp { + export interface ServiceAccountJson { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Header { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + } + + /** + * RunConfig changes what subsequent commands run with: user and work_dir replace + * the current values, env_vars merge over them. Commands already running are + * unaffected. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface BoxListParams extends ItemsCursorGetPaginationParams { + /** + * Filter by creator identity. Only 'me' is supported. + */ + created_by?: string; + + /** + * Filter by label. Repeatable; all must match. Use 'key' to match on key presence + * or 'key=value' for equality. + */ + label?: Array; + + /** + * Deprecated: use page_size. Maximum number of results + */ + limit?: number; + + /** + * Filter by name substring + */ + name_contains?: string; + + /** + * Deprecated: use cursor. Pagination offset + */ + offset?: number; + + /** + * Sort column (name, status, created_at, stopped_at, idle_ttl_seconds, + * delete_after_stop_seconds) + */ + sort_by?: string; + + /** + * Deprecated: use sort_order. Sort direction (asc, desc) + */ + sort_direction?: string; + + /** + * Sort direction (asc, desc) + */ + sort_order?: string; + + /** + * Filter by status (provisioning, ready, failed, stopped, deleting) + */ + status?: string; + + /** + * Filter by workspace resource tag value IDs; all must match + */ + tag_value_id?: Array; +} + +export interface BoxCreateSnapshotParams { + name: string; + + /** + * if omitted, creates a fresh checkpoint from the running VM + */ + checkpoint?: string; + + /** + * Description says what this snapshot's image can do, so a caller can hand it to + * an agent as a capability summary. At most 1024 characters. + */ + description?: string; + + /** + * sandbox-local Docker image to export + */ + docker_image?: string; + + /** + * required for Docker image export unless the sandbox has a capacity + */ + fs_capacity_bytes?: number; + + /** + * IncludeMemory, when true, captures a full VM memory snapshot alongside the + * filesystem clone. Only honored when the sandbox is running AND Checkpoint is + * omitted (i.e. a fresh in-VM checkpoint is requested). Defaults to false to keep + * snapshots small unless memory restore is explicitly desired. + */ + include_memory?: boolean; + + /** + * Labels seed the captured snapshot's labels. + */ + labels?: { [key: string]: string }; + + /** + * RunConfig overrides the runtime configuration the snapshot carries: for a + * docker_image export, the image's USER, WORKDIR and ENV; for a capture of the + * running VM, the sandbox's own. user and work_dir replace, env_vars merge. + */ + run_config?: BoxCreateSnapshotParams.RunConfig; + + /** + * mutable Docker-style tag; defaults to "latest" + */ + tag?: string; +} + +export namespace BoxCreateSnapshotParams { + /** + * RunConfig overrides the runtime configuration the snapshot carries: for a + * docker_image export, the image's USER, WORKDIR and ENV; for a capture of the + * running VM, the sandbox's own. user and work_dir replace, env_vars merge. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface BoxDeleteServiceURLParams { + /** + * Port to stop sharing. Omit to stop sharing every port. + */ + port?: number; +} + +export interface BoxGenerateDownloadURLParams { + path: string; + + content_disposition?: string; + + content_type?: string; + + /** + * CSPSandbox false serves the file with no CSP sandbox directive; omit to keep it. + */ + csp_sandbox?: boolean; + + /** + * CSPSandboxFlags loosen the CSP sandbox the file is served under; omit for the + * most restrictive policy. + */ + csp_sandbox_flags?: Array< + | 'allow-downloads' + | 'allow-forms' + | 'allow-modals' + | 'allow-orientation-lock' + | 'allow-pointer-lock' + | 'allow-popups' + | 'allow-presentation' + | 'allow-scripts' + | 'allow-top-navigation-by-user-activation' + | 'allow-same-origin' + >; + + /** + * CSPSourceBundles allow the served file to fetch from named third-party origins; + * omit to send no fetch directive. + */ + csp_source_bundles?: Array<'cdnjs' | 'google-fonts' | 'jsdelivr' | 'unpkg' | 'none' | 'any'>; + + /** + * ExpiresInSeconds is optional; a link with no expiry never expires. + */ + expires_in_seconds?: number; +} + +export interface BoxGenerateServiceURLParams { + /** + * Access selects the login mode, mutually exclusive with the minted token. Omit + * the field for token mode: mint a short-lived service token (default). + * "restricted" — LangSmith login: the sandbox's creator, or any user with + * SandboxesExec on it (admins by default). "workspace" — LangSmith login: any + * member of the owning workspace. "off" — remove an existing LangSmith login grant + * and mint a token. A LangSmith login grant is durable; token mode is refused + * (409) while one exists. + */ + access?: 'restricted' | 'workspace' | 'off'; + + expires_in_seconds?: number; + + port?: number; +} + +export interface BoxListServiceURLsParams extends ItemsCursorGetPaginationParams {} + +export declare namespace Boxes { + export { + type BoxListServiceURLsResponse as BoxListServiceURLsResponse, + type BoxListServiceURLsResponsesItemsCursorGetPagination as BoxListServiceURLsResponsesItemsCursorGetPagination, + type BoxCreateParams as BoxCreateParams, + type BoxUpdateParams as BoxUpdateParams, + type BoxListParams as BoxListParams, + type BoxCreateSnapshotParams as BoxCreateSnapshotParams, + type BoxDeleteServiceURLParams as BoxDeleteServiceURLParams, + type BoxGenerateDownloadURLParams as BoxGenerateDownloadURLParams, + type BoxGenerateServiceURLParams as BoxGenerateServiceURLParams, + type BoxListServiceURLsParams as BoxListServiceURLsParams, + }; +} + +export { type SandboxResponsesItemsCursorGetPagination }; diff --git a/src/resources/sandboxes/index.ts b/src/resources/sandboxes/index.ts new file mode 100644 index 0000000..759f82b --- /dev/null +++ b/src/resources/sandboxes/index.ts @@ -0,0 +1,44 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + Boxes, + type BoxListServiceURLsResponse, + type BoxCreateParams, + type BoxUpdateParams, + type BoxListParams, + type BoxCreateSnapshotParams, + type BoxDeleteServiceURLParams, + type BoxGenerateDownloadURLParams, + type BoxGenerateServiceURLParams, + type BoxListServiceURLsParams, + type BoxListServiceURLsResponsesItemsCursorGetPagination, +} from './boxes'; +export { + Registries, + type RegistryListResponse, + type RegistryResponse, + type RegistryCreateParams, + type RegistryUpdateParams, + type RegistryListParams, +} from './registries'; +export { + Sandboxes, + type DownloadURLResponse, + type SandboxListResponse, + type SandboxResponse, + type SandboxStatusResponse, + type ServiceURLResponse, + type SnapshotListResponse, + type SnapshotResponse, + type SandboxListUsageCostsResponse, + type SandboxListUsageCostsParams, + type SandboxResponsesItemsCursorGetPagination, + type SnapshotResponsesItemsCursorGetPagination, + type SandboxListUsageCostsResponsesItemsCursorGetPagination, +} from './sandboxes'; +export { + Snapshots, + type SnapshotRetrieveByNameResponse, + type SnapshotCreateParams, + type SnapshotListParams, +} from './snapshots'; diff --git a/src/resources/sandboxes/registries.ts b/src/resources/sandboxes/registries.ts new file mode 100644 index 0000000..0612611 --- /dev/null +++ b/src/resources/sandboxes/registries.ts @@ -0,0 +1,135 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Registries extends APIResource { + /** + * Create a sandbox registry for pulling private images. + */ + create(body: RegistryCreateParams, options?: RequestOptions): APIPromise { + return this._client.post('/api/v2/sandboxes/registries', { body, ...options }); + } + + /** + * Get a sandbox registry by name. + */ + retrieve(name: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/sandboxes/registries/${name}`, options); + } + + /** + * Update a sandbox registry's name and/or credentials. + */ + update(name: string, body: RegistryUpdateParams, options?: RequestOptions): APIPromise { + return this._client.patch(path`/api/v2/sandboxes/registries/${name}`, { body, ...options }); + } + + /** + * List sandbox registries for pulling private images. + */ + list( + query: RegistryListParams | null | undefined = {}, + options?: RequestOptions, + ): APIPromise { + return this._client.get('/api/v2/sandboxes/registries', { query, ...options }); + } + + /** + * Delete a sandbox registry by name. + */ + delete(name: string, options?: RequestOptions): APIPromise { + return this._client.delete(path`/api/v2/sandboxes/registries/${name}`, { + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } +} + +export interface RegistryListResponse { + offset?: number; + + registries?: Array; +} + +export interface RegistryResponse { + id?: string; + + auth_type?: 'DOCKER_CONFIG' | 'AWS_ROLE'; + + aws_role_arn?: string; + + created_at?: string; + + created_by?: string; + + name?: string; + + provider?: 'DOCKER_REGISTRY' | 'HARBOR' | 'GHCR' | 'ECR' | 'GAR' | 'DOCKER_HUB'; + + repository_search_mode?: 'GLOBAL' | 'SCOPED' | 'NONE'; + + updated_at?: string; + + updated_by?: string; + + url?: string; +} + +export interface RegistryCreateParams { + name: string; + + url: string; + + auth_type?: 'DOCKER_CONFIG' | 'AWS_ROLE'; + + aws_role_arn?: string; + + password?: string; + + username?: string; +} + +export interface RegistryUpdateParams { + auth_type?: 'DOCKER_CONFIG' | 'AWS_ROLE'; + + aws_role_arn?: string; + + name?: string; + + password?: string; + + url?: string; + + username?: string; +} + +export interface RegistryListParams { + /** + * Maximum number of registries to return + */ + limit?: number; + + /** + * Filter to registries whose name contains this substring + */ + name_contains?: string; + + /** + * Number of registries to skip + */ + offset?: number; +} + +export declare namespace Registries { + export { + type RegistryListResponse as RegistryListResponse, + type RegistryResponse as RegistryResponse, + type RegistryCreateParams as RegistryCreateParams, + type RegistryUpdateParams as RegistryUpdateParams, + type RegistryListParams as RegistryListParams, + }; +} diff --git a/src/resources/sandboxes/sandboxes.ts b/src/resources/sandboxes/sandboxes.ts new file mode 100644 index 0000000..c2434e9 --- /dev/null +++ b/src/resources/sandboxes/sandboxes.ts @@ -0,0 +1,949 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as BoxesAPI from './boxes'; +import { + BoxCreateParams, + BoxCreateSnapshotParams, + BoxDeleteServiceURLParams, + BoxGenerateDownloadURLParams, + BoxGenerateServiceURLParams, + BoxListParams, + BoxListServiceURLsParams, + BoxListServiceURLsResponse, + BoxListServiceURLsResponsesItemsCursorGetPagination, + BoxUpdateParams, + Boxes, +} from './boxes'; +import * as RegistriesAPI from './registries'; +import { + Registries, + RegistryCreateParams, + RegistryListParams, + RegistryListResponse, + RegistryResponse, + RegistryUpdateParams, +} from './registries'; +import * as SnapshotsAPI from './snapshots'; +import { + SnapshotCreateParams, + SnapshotListParams, + SnapshotRetrieveByNameResponse, + Snapshots, +} from './snapshots'; +import { + ItemsCursorGetPagination, + type ItemsCursorGetPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { RequestOptions } from '../../internal/request-options'; + +export class Sandboxes extends APIResource { + boxes: BoxesAPI.Boxes = new BoxesAPI.Boxes(this._client); + registries: RegistriesAPI.Registries = new RegistriesAPI.Registries(this._client); + snapshots: SnapshotsAPI.Snapshots = new SnapshotsAPI.Snapshots(this._client); + + /** + * Returns priced usage per sandbox or snapshot and UTC hour in the half-open + * requested interval. LCU uses the recorded compute amount for sandboxes; + * snapshots have zero LCU. LSU allocates the recorded workspace storage amount + * proportionally to attributed bytes, including checkpoints on their sandbox and + * snapshots as separate resources. Resource filters preserve each resource's + * share. Rate changes do not reprice recorded amounts. An access-filtered page can + * have no items and a non-null next_cursor; continue until next_cursor is null. + */ + listUsageCosts( + query: SandboxListUsageCostsParams, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + '/api/v2/sandboxes/usage/costs', + ItemsCursorGetPagination, + { query, ...options }, + ); + } +} + +export type SandboxListUsageCostsResponsesItemsCursorGetPagination = + ItemsCursorGetPagination; + +export type SandboxResponsesItemsCursorGetPagination = ItemsCursorGetPagination; + +export type SnapshotResponsesItemsCursorGetPagination = ItemsCursorGetPagination; + +export interface DownloadURLResponse { + token: string; + + download_url: string; + + /** + * ExpiresAt is null for a link that never expires. + */ + expires_at?: string | null; +} + +export interface SandboxListResponse { + /** + * This page of sandboxes. + */ + items?: Array; + + /** + * Cursor for the next page, or null on the last page. A non-null value is the only + * signal that more pages exist. Treat it as opaque. + */ + next_cursor?: string; + + /** + * Deprecated: use next_cursor. Offset to request for the next page, or 0 when no + * pages remain. + */ + offset?: number; + + /** + * Deprecated: use items. Duplicates items. + */ + sandboxes?: Array; +} + +export interface SandboxResponse { + id?: string; + + /** + * AccessDelegation is the LangSmith access this sandbox was granted, absent when + * it has none. It is reported exactly as it was requested at create. + */ + access_delegation?: SandboxResponse.AccessDelegation; + + cpu_millicores?: number; + + created_at?: string; + + created_by?: string; + + dataplane_url?: string; + + delete_after_stop_seconds?: number; + + fs_capacity_bytes?: number; + + idle_ttl_seconds?: number; + + labels?: { [key: string]: string }; + + mem_bytes?: number; + + mount_config?: SandboxResponse.MountConfig; + + name?: string; + + preserve_memory_on_stop?: boolean; + + proxy_config?: SandboxResponse.ProxyConfig; + + /** + * RunConfig is what the sandbox's commands run with: the user, working directory + * and base env beneath env_vars. + */ + run_config?: SandboxResponse.RunConfig; + + size_class?: string; + + snapshot_id?: string; + + status?: string; + + status_message?: string; + + stopped_at?: string; + + updated_at?: string; + + updated_by?: string; + + vcpus?: number; +} + +export namespace SandboxResponse { + /** + * AccessDelegation is the LangSmith access this sandbox was granted, absent when + * it has none. It is reported exactly as it was requested at create. + */ + export interface AccessDelegation { + mode: 'INHERIT' | 'EXPLICIT'; + + permissions?: Array; + } + + export interface MountConfig { + auth?: MountConfig.Auth; + + mounts?: Array< + | MountConfig.SandboxapiS3BucketMountSpec + | MountConfig.SandboxapiGcsBucketMountSpec + | MountConfig.SandboxapiGitRepoMountSpec + | MountConfig.SandboxapiContextHubRepoMountSpec + >; + } + + export namespace MountConfig { + export interface Auth { + aws?: Auth.SandboxesSandboxAwsMountRoleAuthConfig | Auth.SandboxesSandboxAwsMountStaticAuthConfig; + + gcp?: Auth.Gcp; + } + + export namespace Auth { + export interface SandboxesSandboxAwsMountRoleAuthConfig { + /** + * IAM role to assume with permissions scoped to the configured S3 mounts. Mutually + * exclusive with static credentials. Configure only at creation. + */ + role_arn: string; + } + + export interface SandboxesSandboxAwsMountStaticAuthConfig { + access_key_id: SandboxesSandboxAwsMountStaticAuthConfig.AccessKeyID; + + secret_access_key: SandboxesSandboxAwsMountStaticAuthConfig.SecretAccessKey; + + /** + * IAM role to assume with permissions scoped to the configured S3 mounts. Mutually + * exclusive with static credentials. Configure only at creation. + */ + role_arn?: ''; + } + + export namespace SandboxesSandboxAwsMountStaticAuthConfig { + export interface AccessKeyID { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + + export interface SecretAccessKey { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Gcp { + service_account_json: Gcp.ServiceAccountJson; + } + + export namespace Gcp { + export interface ServiceAccountJson { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + } + + export interface SandboxapiS3BucketMountSpec { + id: string; + + mount_path: string; + + s3: SandboxapiS3BucketMountSpec.S3; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiS3BucketMountSpec.Cache; + + contexthub?: SandboxapiS3BucketMountSpec.Contexthub; + + gcs?: SandboxapiS3BucketMountSpec.Gcs; + + git?: SandboxapiS3BucketMountSpec.Git; + + read_only?: boolean; + } + + export namespace SandboxapiS3BucketMountSpec { + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + } + + export interface SandboxapiGcsBucketMountSpec { + id: string; + + gcs: SandboxapiGcsBucketMountSpec.Gcs; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiGcsBucketMountSpec.Cache; + + contexthub?: SandboxapiGcsBucketMountSpec.Contexthub; + + git?: SandboxapiGcsBucketMountSpec.Git; + + read_only?: boolean; + + s3?: SandboxapiGcsBucketMountSpec.S3; + } + + export namespace SandboxapiGcsBucketMountSpec { + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + + export interface SandboxapiGitRepoMountSpec { + id: string; + + git: SandboxapiGitRepoMountSpec.Git; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiGitRepoMountSpec.Cache; + + contexthub?: SandboxapiGitRepoMountSpec.Contexthub; + + gcs?: SandboxapiGitRepoMountSpec.Gcs; + + read_only?: boolean; + + s3?: SandboxapiGitRepoMountSpec.S3; + } + + export namespace SandboxapiGitRepoMountSpec { + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + + export interface SandboxapiContextHubRepoMountSpec { + id: string; + + contexthub: SandboxapiContextHubRepoMountSpec.Contexthub; + + mount_path: string; + + type: 's3' | 'gcs' | 'git' | 'contexthub'; + + cache?: SandboxapiContextHubRepoMountSpec.Cache; + + gcs?: SandboxapiContextHubRepoMountSpec.Gcs; + + git?: SandboxapiContextHubRepoMountSpec.Git; + + read_only?: boolean; + + s3?: SandboxapiContextHubRepoMountSpec.S3; + } + + export namespace SandboxapiContextHubRepoMountSpec { + export interface Contexthub { + /** + * Repo is the Context Hub repository to sync, as "owner/repo" (e.g. "-/my-agent", + * where "-" is the current workspace). The repo's latest commit tree is mirrored + * into the mount path. + */ + repo: string; + + /** + * InitialPullOnly syncs the repo once at startup instead of polling for updates + * for the sandbox's lifetime. + */ + initial_pull_only?: boolean; + } + + export interface Cache { + max_size_bytes?: number; + + writeback_seconds?: number; + } + + export interface Gcs { + bucket: string; + + prefix?: string; + } + + export interface Git { + remote_url: string; + + ref?: Git.Ref; + + refresh_interval_seconds?: number; + } + + export namespace Git { + export interface Ref { + name: string; + + type: 'branch' | 'tag'; + } + } + + export interface S3 { + bucket: string; + + region: string; + + endpoint_url?: string; + + path_style?: boolean; + + prefix?: string; + } + } + } + + export interface ProxyConfig { + access_control?: ProxyConfig.AccessControl; + + callbacks?: Array; + + /** + * Description says what this configuration as a whole lets the sandbox reach, + * complementing the per-rule descriptions. At most 1024 characters. + */ + description?: string; + + no_proxy?: Array; + + rules?: Array; + } + + export namespace ProxyConfig { + export interface AccessControl { + allow_list?: Array; + + deny_list?: Array; + } + + export interface Callback { + match_hosts: Array; + + ttl_seconds: number; + + url: string; + + full_request?: boolean; + + request_headers?: Array; + } + + export namespace Callback { + export interface RequestHeader { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Rule { + name: string; + + aws?: Rule.SandboxesProxyAwsRoleConfig | Rule.SandboxesProxyAwsStaticConfig; + + /** + * Description says what this rule lets the sandbox reach, so an agent driving the + * sandbox can be told its capabilities. At most 1024 characters. + */ + description?: string; + + enabled?: boolean; + + /** + * EnvVars are plaintext env vars set for every command in the sandbox while this + * rule is enabled. Use them for tools that refuse to run unless a credential env + * var is present (e.g. gh needs GH_TOKEN) even though this rule injects the real + * credential on the wire — set a dummy value here so the command starts. Explicit + * per-sandbox env_vars win over these, and provider-managed (AWS/GCP) vars win + * over both. + */ + env_vars?: { [key: string]: string }; + + gcp?: Rule.Gcp; + + headers?: Array; + + /** + * MatchHeaders restricts a header injection rule to requests carrying every listed + * header, each written "name: value" with a lowercase name and an exact value. + * Pair with headers of the same name to swap a placeholder the sandbox sends (e.g. + * "authorization: Bearer account-b") for a real credential, so one host can serve + * several accounts. Rules are evaluated in order and the first match wins. + */ + match_headers?: Array; + + /** + * MatchHosts is only accepted for header injection rules. Provider auth rules use + * built-in host matching. + */ + match_hosts?: Array; + + match_paths?: Array; + + type?: string; + } + + export namespace Rule { + export interface SandboxesProxyAwsRoleConfig { + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn: string; + } + + export interface SandboxesProxyAwsStaticConfig { + access_key_id: SandboxesProxyAwsStaticConfig.AccessKeyID; + + secret_access_key: SandboxesProxyAwsStaticConfig.SecretAccessKey; + + /** + * RoleARN selects automatically renewed IAM-role credentials instead of static + * keys. Access follows the role's effective AWS permissions, not the sandbox's + * mount scope. Configure at creation; the role cannot be changed afterward. + */ + role_arn?: ''; + } + + export namespace SandboxesProxyAwsStaticConfig { + export interface AccessKeyID { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + + export interface SecretAccessKey { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Gcp { + scopes: Array; + + service_account_json: Gcp.ServiceAccountJson; + } + + export namespace Gcp { + export interface ServiceAccountJson { + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + + export interface Header { + name: string; + + type: 'plaintext' | 'opaque' | 'workspace_secret'; + + is_set?: boolean; + + value?: string; + } + } + } + + /** + * RunConfig is what the sandbox's commands run with: the user, working directory + * and base env beneath env_vars. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface SandboxStatusResponse { + status?: string; + + status_message?: string; +} + +export interface ServiceURLResponse { + /** + * Token and ExpiresAt are empty in LangSmith login mode (no token is minted). + */ + token?: string; + + /** + * Access echoes the enabled LangSmith login level ("restricted"/"workspace"); + * omitted in token mode. + */ + access?: 'restricted' | 'workspace'; + + browser_url?: string; + + expires_at?: string; + + service_url?: string; +} + +export interface SnapshotListResponse { + /** + * This page of snapshots. + */ + items?: Array; + + /** + * Cursor for the next page, or null on the last page. A non-null value is the only + * signal that more pages exist. Treat it as opaque. + */ + next_cursor?: string; + + /** + * Deprecated: use next_cursor. Offset to request for the next page, or 0 when no + * pages remain. + */ + offset?: number; + + /** + * Deprecated: use items. Duplicates items. + */ + snapshots?: Array; +} + +export interface SnapshotResponse { + id?: string; + + created_at?: string; + + created_by?: string; + + /** + * Description says what this snapshot's image can do, so a caller can hand it to + * an agent as a capability summary. + */ + description?: string; + + docker_image?: string; + + fs_capacity_bytes?: number; + + fs_used_bytes?: number; + + image_digest?: string; + + labels?: { [key: string]: string }; + + /** + * MemorySnapshotSizeBytes is non-nil iff the snapshot was captured with VM memory + * state. A non-nil value is the canonical signal that this snapshot can + * warm-restore from memory; nil means rootfs only. + */ + memory_snapshot_size_bytes?: number; + + name?: string; + + registry_id?: string; + + /** + * RunConfig is what sandboxes from this snapshot boot with. Absent on snapshots + * built before it was recorded, which run as root with their own env. + */ + run_config?: SnapshotResponse.RunConfig; + + source_sandbox_id?: string; + + status?: string; + + status_message?: string; + + /** + * Tags currently resolving to this snapshot, under Name. A snapshot with no tags + * is dangling — addressable only by id. + */ + tags?: Array; + + updated_at?: string; +} + +export namespace SnapshotResponse { + /** + * RunConfig is what sandboxes from this snapshot boot with. Absent on snapshots + * built before it was recorded, which run as root with their own env. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface SandboxListUsageCostsResponse { + /** + * Recorded compute usage in LangSmith Compute Units (LCU), as a decimal string + * with up to six fractional digits and trailing zeros omitted. Snapshots return + * "0". + */ + lcu: string; + + /** + * Allocated storage usage in LangSmith Storage Units (LSU), as a decimal string + * with up to six fractional digits and trailing zeros omitted. + */ + lsu: string; + + period_start: string; + + resource_id: string; + + resource_type: 'SANDBOX' | 'SNAPSHOT'; +} + +export interface SandboxListUsageCostsParams extends ItemsCursorGetPaginationParams { + /** + * Exclusive RFC3339 end time; the range must not exceed 31 days + */ + end_time: string; + + /** + * Inclusive RFC3339 start time + */ + start_time: string; + + /** + * HOUR returns hourly buckets. RESOURCE sums each resource over the requested + * interval and sets period_start to start_time. + */ + granularity?: 'HOUR' | 'RESOURCE'; + + /** + * Resource UUID filter; repeat this parameter up to 100 times + */ + resource_ids?: Array; + + /** + * Resource type filter + */ + resource_type?: 'SANDBOX' | 'SNAPSHOT'; +} + +Sandboxes.Boxes = Boxes; +Sandboxes.Registries = Registries; +Sandboxes.Snapshots = Snapshots; + +export declare namespace Sandboxes { + export { + type DownloadURLResponse as DownloadURLResponse, + type SandboxListResponse as SandboxListResponse, + type SandboxResponse as SandboxResponse, + type SandboxStatusResponse as SandboxStatusResponse, + type ServiceURLResponse as ServiceURLResponse, + type SnapshotListResponse as SnapshotListResponse, + type SnapshotResponse as SnapshotResponse, + type SandboxListUsageCostsResponse as SandboxListUsageCostsResponse, + type SandboxListUsageCostsResponsesItemsCursorGetPagination as SandboxListUsageCostsResponsesItemsCursorGetPagination, + type SandboxListUsageCostsParams as SandboxListUsageCostsParams, + }; + + export { + Boxes as Boxes, + type BoxListServiceURLsResponse as BoxListServiceURLsResponse, + type BoxListServiceURLsResponsesItemsCursorGetPagination as BoxListServiceURLsResponsesItemsCursorGetPagination, + type BoxCreateParams as BoxCreateParams, + type BoxUpdateParams as BoxUpdateParams, + type BoxListParams as BoxListParams, + type BoxCreateSnapshotParams as BoxCreateSnapshotParams, + type BoxDeleteServiceURLParams as BoxDeleteServiceURLParams, + type BoxGenerateDownloadURLParams as BoxGenerateDownloadURLParams, + type BoxGenerateServiceURLParams as BoxGenerateServiceURLParams, + type BoxListServiceURLsParams as BoxListServiceURLsParams, + }; + + export { + Registries as Registries, + type RegistryListResponse as RegistryListResponse, + type RegistryResponse as RegistryResponse, + type RegistryCreateParams as RegistryCreateParams, + type RegistryUpdateParams as RegistryUpdateParams, + type RegistryListParams as RegistryListParams, + }; + + export { + Snapshots as Snapshots, + type SnapshotRetrieveByNameResponse as SnapshotRetrieveByNameResponse, + type SnapshotCreateParams as SnapshotCreateParams, + type SnapshotListParams as SnapshotListParams, + }; +} diff --git a/src/resources/sandboxes/snapshots.ts b/src/resources/sandboxes/snapshots.ts new file mode 100644 index 0000000..a92af71 --- /dev/null +++ b/src/resources/sandboxes/snapshots.ts @@ -0,0 +1,196 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as SandboxesAPI from './sandboxes'; +import { SnapshotResponsesItemsCursorGetPagination } from './sandboxes'; +import { APIPromise } from '../../core/api-promise'; +import { + ItemsCursorGetPagination, + type ItemsCursorGetPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Snapshots extends APIResource { + /** + * Create a snapshot from a Docker image (async build). Names use lowercase + * registry-style components separated by slashes, up to 255 characters. The + * system/ namespace is read-only. + */ + create(body: SnapshotCreateParams, options?: RequestOptions): APIPromise { + return this._client.post('/api/v2/sandboxes/snapshots', { body, ...options }); + } + + /** + * Get a sandbox snapshot by ID or a registry-style reference, including + * system/default:latest. URL-encode references containing slashes. A bare name + * means name:latest, falling back to the newest ready untagged snapshot of that + * name. To list the tags under a name, use + * /api/v2/sandboxes/snapshots-by-name/{name}. + */ + retrieve(snapshotID: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/sandboxes/snapshots/${snapshotID}`, options); + } + + /** + * List workspace and published system snapshots, with optional filtering, sorting, + * and pagination. Page with page_size and cursor: replay the response's + * next_cursor until it comes back null, which is the only signal that no pages + * remain. Cursors are opaque and only valid on this endpoint; do not parse or + * construct one. + */ + list( + query: SnapshotListParams | null | undefined = {}, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + '/api/v2/sandboxes/snapshots', + ItemsCursorGetPagination, + { query, ...options }, + ); + } + + /** + * Delete a snapshot by ID or by a Docker-style name[:tag] reference. The + * underlying storage is reclaimed asynchronously. + */ + delete(snapshotID: string, options?: RequestOptions): APIPromise { + return this._client.delete(path`/api/v2/sandboxes/snapshots/${snapshotID}`, { + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } + + /** + * Get a snapshot name and every tag under it, with the snapshot each tag resolves + * to. To fetch one snapshot, use /api/v2/sandboxes/snapshots/{snapshot_id}. + */ + retrieveByName(name: string, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/sandboxes/snapshots-by-name/${name}`, options); + } +} + +export interface SnapshotRetrieveByNameResponse { + name?: string; + + tags?: Array; +} + +export namespace SnapshotRetrieveByNameResponse { + export interface Tag { + snapshot_id?: string; + + tag?: string; + } +} + +export interface SnapshotCreateParams { + docker_image: string; + + fs_capacity_bytes: number; + + name: string; + + /** + * Description says what this snapshot's image can do, so a caller can hand it to + * an agent as a capability summary. At most 1024 characters. + */ + description?: string; + + /** + * Labels seed the snapshot's labels, overriding any label of the same key derived + * from the Docker image. + */ + labels?: { [key: string]: string }; + + registry_id?: string; + + /** + * RunConfig overrides the runtime configuration taken from the Docker image. Every + * sandbox created from the snapshot runs as the image's USER, in its WORKDIR, with + * its ENV beneath the sandbox's own env_vars; user and work_dir given here replace + * the image's, and env_vars merge over it. + */ + run_config?: SnapshotCreateParams.RunConfig; + + /** + * mutable Docker-style tag; defaults to "latest" + */ + tag?: string; +} + +export namespace SnapshotCreateParams { + /** + * RunConfig overrides the runtime configuration taken from the Docker image. Every + * sandbox created from the snapshot runs as the image's USER, in its WORKDIR, with + * its ENV beneath the sandbox's own env_vars; user and work_dir given here replace + * the image's, and env_vars merge over it. + */ + export interface RunConfig { + env_vars?: { [key: string]: string }; + + user?: string; + + work_dir?: string; + } +} + +export interface SnapshotListParams extends ItemsCursorGetPaginationParams { + /** + * Filter by creator identity. Only 'me' is supported. + */ + created_by?: string; + + /** + * Filter by label. Repeatable; all must match. Use 'key' to match on key presence + * or 'key=value' for equality. + */ + label?: Array; + + /** + * Deprecated: use page_size. Maximum number of results + */ + limit?: number; + + /** + * Filter by name substring + */ + name_contains?: string; + + /** + * Deprecated: use cursor. Pagination offset + */ + offset?: number; + + /** + * Sort column (name, status, created_at) + */ + sort_by?: string; + + /** + * Deprecated: use sort_order. Sort direction (asc, desc) + */ + sort_direction?: string; + + /** + * Sort direction (asc, desc) + */ + sort_order?: string; + + /** + * Filter by status (building, ready, failed, deleting) + */ + status?: string; +} + +export declare namespace Snapshots { + export { + type SnapshotRetrieveByNameResponse as SnapshotRetrieveByNameResponse, + type SnapshotCreateParams as SnapshotCreateParams, + type SnapshotListParams as SnapshotListParams, + }; +} + +export { type SnapshotResponsesItemsCursorGetPagination }; diff --git a/src/resources/shared.ts b/src/resources/shared.ts new file mode 100644 index 0000000..ac99272 --- /dev/null +++ b/src/resources/shared.ts @@ -0,0 +1,15 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export interface AgentAddress { + /** + * `id` is the Agent's user-assigned id. + */ + id: string; + + /** + * `environment` is the Agent environment. + */ + environment: 'LOCAL' | 'DEVELOPMENT' | 'STAGING' | 'PRODUCTION'; + + kind: 'AGENT'; +} diff --git a/src/resources/threads.ts b/src/resources/threads.ts new file mode 100644 index 0000000..705f670 --- /dev/null +++ b/src/resources/threads.ts @@ -0,0 +1,3 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export * from './threads/index'; diff --git a/src/resources/threads/index.ts b/src/resources/threads/index.ts new file mode 100644 index 0000000..bfd2c64 --- /dev/null +++ b/src/resources/threads/index.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +export { + Share, + type ShareCreateResponse, + type ShareRetrieveResponse, + type ShareCreateParams, + type ShareRetrieveParams, + type ShareDeleteParams, +} from './share'; +export { + Threads, + type Thread, + type ThreadStats, + type ThreadTrace, + type ThreadAggregateStatsResponse, + type ThreadAggregateStatsParams, + type ThreadListTracesParams, + type ThreadQueryParams, + type ThreadStatsParams, + type ThreadTracesItemsCursorGetPagination, + type ThreadsItemsCursorPostPagination, +} from './threads'; diff --git a/src/resources/threads/share.ts b/src/resources/threads/share.ts new file mode 100644 index 0000000..9b80a97 --- /dev/null +++ b/src/resources/threads/share.ts @@ -0,0 +1,109 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import { APIPromise } from '../../core/api-promise'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Share extends APIResource { + /** + * Mints a public share token for a thread. Idempotent: sharing an already-shared + * thread returns the existing token. + * + * @example + * ```ts + * const share = await client.threads.share.create( + * 'thread_id', + * { project_id: '018e4c7e-a9fb-7ef0-a5b6-6ea3a82e9327' }, + * ); + * ``` + */ + create( + threadID: string, + body: ShareCreateParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post(path`/api/v2/threads/${threadID}/share`, { body, ...options }); + } + + /** + * Returns the share token for a thread. The token is omitted when the thread is + * not shared. Gated on runs:share so the control's state matches the control's + * permission. + * + * @example + * ```ts + * const share = await client.threads.share.retrieve( + * 'thread_id', + * { project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e' }, + * ); + * ``` + */ + retrieve( + threadID: string, + query: ShareRetrieveParams, + options?: RequestOptions, + ): APIPromise { + return this._client.get(path`/api/v2/threads/${threadID}/share`, { query, ...options }); + } + + /** + * Deletes the share token for a thread. Idempotent: returns 204 whether or not a + * share token existed. Deliberately does not verify the thread still exists. + * + * @example + * ```ts + * await client.threads.share.delete('thread_id', { + * project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * }); + * ``` + */ + delete(threadID: string, params: ShareDeleteParams, options?: RequestOptions): APIPromise { + const { project_id } = params; + return this._client.delete(path`/api/v2/threads/${threadID}/share`, { + query: { project_id }, + ...options, + headers: buildHeaders([{ Accept: '*/*' }, options?.headers]), + }); + } +} + +export interface ShareCreateResponse { + share_token?: string; +} + +export interface ShareRetrieveResponse { + share_token?: string; +} + +export interface ShareCreateParams { + /** + * project_id is the tracing project UUID containing the thread. + */ + project_id: string; +} + +export interface ShareRetrieveParams { + /** + * Project UUID + */ + project_id: string; +} + +export interface ShareDeleteParams { + /** + * Project UUID + */ + project_id: string; +} + +export declare namespace Share { + export { + type ShareCreateResponse as ShareCreateResponse, + type ShareRetrieveResponse as ShareRetrieveResponse, + type ShareCreateParams as ShareCreateParams, + type ShareRetrieveParams as ShareRetrieveParams, + type ShareDeleteParams as ShareDeleteParams, + }; +} diff --git a/src/resources/threads/threads.ts b/src/resources/threads/threads.ts new file mode 100644 index 0000000..7fa9983 --- /dev/null +++ b/src/resources/threads/threads.ts @@ -0,0 +1,1209 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../../core/resource'; +import * as ShareAPI from './share'; +import { + Share, + ShareCreateParams, + ShareCreateResponse, + ShareDeleteParams, + ShareRetrieveParams, + ShareRetrieveResponse, +} from './share'; +import { APIPromise } from '../../core/api-promise'; +import { + ItemsCursorGetPagination, + type ItemsCursorGetPaginationParams, + ItemsCursorPostPagination, + type ItemsCursorPostPaginationParams, + PagePromise, +} from '../../core/pagination'; +import { buildHeaders } from '../../internal/headers'; +import { RequestOptions } from '../../internal/request-options'; +import { path } from '../../internal/utils/path'; + +export class Threads extends APIResource { + share: ShareAPI.Share = new ShareAPI.Share(this._client); + + /** + * GET with body payload — no resources created. Returns aggregate statistics for + * threads in a tracing project. The response includes the thread counts, run + * counts, latency percentiles, rates, token totals, and cost totals requested in + * `select`. + * + * Self-hosted deployments require LangSmith `v0.17` or later. + * + * @example + * ```ts + * const response = await client.threads.aggregateStats({ + * project_id: '0190a1b2-c3d4-7ef0-a5b6-6ea3a82e9328', + * select: [ + * 'THREAD_COUNT', + * 'TRACE_COUNT', + * 'TOTAL_TOKENS', + * 'TOTAL_COST', + * ], + * }); + * ``` + */ + aggregateStats( + body: ThreadAggregateStatsParams, + options?: RequestOptions, + ): APIPromise { + return this._client.post('/api/v2/threads/stats', { body, ...options }); + } + + /** + * Retrieve all traces belonging to a specific thread within a project. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const threadTrace of client.threads.listTraces( + * 'thread_id', + * { project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e' }, + * )) { + * // ... + * } + * ``` + */ + listTraces( + threadID: string, + query: ThreadListTracesParams, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList( + path`/api/v2/threads/${threadID}/traces`, + ItemsCursorGetPagination, + { query, ...options }, + ); + } + + /** + * Query threads within a project (session), with cursor-based pagination. Returns + * threads matching the given time range and optional filters. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const thread of client.threads.query()) { + * // ... + * } + * ``` + */ + query( + params: ThreadQueryParams, + options?: RequestOptions, + ): PagePromise { + const { Accept, ...body } = params; + return this._client.getAPIList('/api/v2/threads/query', ItemsCursorPostPagination, { + body, + method: 'post', + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } + + /** + * Compute aggregate stats for a single thread (turn count, latency percentiles, + * token/cost sums, and detail breakdowns) within a project. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const threadStats = await client.threads.stats( + * 'thread_id', + * { + * selects: ['TURNS'], + * session_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * }, + * ); + * ``` + */ + stats(threadID: string, query: ThreadStatsParams, options?: RequestOptions): APIPromise { + return this._client.get(path`/api/v2/threads/${threadID}/stats`, { query, ...options }); + } +} + +export type ThreadTracesItemsCursorGetPagination = ItemsCursorGetPagination; + +export type ThreadsItemsCursorPostPagination = ItemsCursorPostPagination; + +export interface Thread { + /** + * `count` is how many root traces (conversation turns) fall in this thread for the + * query time range. + */ + count?: number; + + /** + * `feedback_stats` is the aggregated feedback across traces in the thread, keyed + * by feedback key; shape matches `feedback_stats` on a single run. + */ + feedback_stats?: { [key: string]: Thread.FeedbackStats }; + + /** + * `first_inputs` is a truncated preview of inputs from the earliest trace in the + * thread for the query window. + */ + first_inputs?: string; + + /** + * `first_trace_id` is the root trace UUID for the chronologically first trace in + * the query time window. + */ + first_trace_id?: string; + + /** + * `last_error` is a short error summary from the most recent failing trace in the + * thread. Absent when there is no error in the window. + */ + last_error?: string; + + /** + * `last_outputs` is a truncated preview of outputs from the latest trace in the + * thread for the query window. + */ + last_outputs?: string; + + /** + * `last_trace_id` is the root trace UUID for the chronologically last trace in the + * query time window. + */ + last_trace_id?: string; + + /** + * `latency_p50` is the approximate median end-to-end latency of traces in the + * thread, in seconds. + */ + latency_p50?: number; + + /** + * `latency_p99` is the approximate 99th percentile end-to-end latency of traces in + * the thread, in seconds. + */ + latency_p99?: number; + + /** + * `max_start_time` is the latest trace start time in the thread (RFC3339 + * date-time). + */ + max_start_time?: string; + + /** + * `min_start_time` is the earliest trace start time in the thread (RFC3339 + * date-time). + */ + min_start_time?: string; + + /** + * `num_errored_turns` is the count of root traces in the thread (within the query + * window) whose status was an error. + */ + num_errored_turns?: number; + + /** + * `start_time` is a reference start time for this row (RFC3339 date-time), such as + * for sorting. + */ + start_time?: string; + + /** + * `thread_id` identifies this conversation thread within the project from the + * request body `project_id`. + */ + thread_id?: string; + + /** + * `total_cost` is the sum of estimated USD cost across those traces. + */ + total_cost?: number; + + /** + * `total_cost_details` sums per-category estimated USD cost across traces in the + * thread. Keys mirror `total_token_details`. + * + * Example: `{"cache_read": 0.012, "reasoning": 0.008}`. + */ + total_cost_details?: { [key: string]: number }; + + /** + * `total_token_details` sums per-category token counts across traces in the + * thread. Keys are model-specific category names (for example `cache_read`, + * `cache_write`, `reasoning`, `audio`). + * + * Example: `{"cache_read": 400, "reasoning": 120}`. + */ + total_token_details?: { [key: string]: number }; + + /** + * `total_tokens` is the sum of token usage across those traces. + */ + total_tokens?: number; + + /** + * `trace_id` is a representative root trace UUID when the summary includes one, + * for example for deep links. + */ + trace_id?: string; +} + +export namespace Thread { + export interface FeedbackStats { + /** + * `avg` is the arithmetic mean of numeric feedback scores for this key on the run, + * or `null` when no numeric score has been recorded (for example purely + * categorical feedback). + */ + avg?: number; + + /** + * `comments` is a sample of human-readable comments attached to feedback points + * for this key, in no particular order. May be empty; is not exhaustive when many + * comments exist. + */ + comments?: Array; + + /** + * `contains_thread_feedback` is true when at least one feedback point for this key + * was submitted at the thread level (rather than at an individual run). Always + * false on responses that already describe a single run in isolation. + */ + contains_thread_feedback?: boolean; + + /** + * `errors` is the number of feedback points recorded as errors rather than + * successful scores (for example an automated evaluator that raised an exception). + * Defaults to 0 when no errors occurred. + */ + errors?: number; + + /** + * `max` is the largest numeric feedback score recorded for this key on the run, or + * `null` when no numeric score has been recorded. + */ + max?: number; + + /** + * `min` is the smallest numeric feedback score recorded for this key on the run, + * or `null` when no numeric score has been recorded. + */ + min?: number; + + /** + * `n` is the number of feedback points recorded for this key on the run. For + * numeric feedback this is the sample size behind `avg`, `min`, `max`, and + * `stdev`; for categorical feedback it is the sum of the `values` counts. + */ + n?: number; + + /** + * `sources` is a sample of feedback sources for this key. Each entry is either a + * plain string identifier (for example `"api"`, `"app"`, `"model"`) or a JSON + * object describing a synthetic source (for example + * `{"type": "__ls_composite_feedback"}` for a computed aggregate). Clients must + * tolerate both shapes. + */ + sources?: Array; + + /** + * `stdev` is the sample standard deviation of numeric feedback scores for this key + * on the run, or `null` when it cannot be computed (for example fewer than two + * numeric scores, or purely categorical feedback). + */ + stdev?: number; + + /** + * `values` is the distribution of categorical feedback labels for this key, + * mapping each label to its occurrence count. Empty (`{}`) for purely numeric + * feedback. + */ + values?: { [key: string]: number }; + } +} + +export interface ThreadStats { + /** + * `completion_cost` is the sum of per-trace completion costs across the thread, in + * USD. Populated when `COMPLETION_COST` is selected. + */ + completion_cost?: number; + + /** + * `completion_cost_details` is the per-sub-category sum of completion cost details + * across the thread. Populated when `COMPLETION_COST_DETAILS` is selected. + */ + completion_cost_details?: ThreadStats.CompletionCostDetails; + + /** + * `completion_token_details` is the per-sub-category sum of completion token + * details across the thread. Populated when `COMPLETION_TOKEN_DETAILS` is + * selected. + */ + completion_token_details?: ThreadStats.CompletionTokenDetails; + + /** + * `completion_tokens` is the sum of per-trace completion token counts across the + * thread. Populated when `COMPLETION_TOKENS` is selected. + */ + completion_tokens?: number; + + /** + * `feedback_stats` aggregates run-level feedback across the thread's traces, keyed + * by feedback key. Populated when `FEEDBACK_STATS` is selected. + */ + feedback_stats?: { [key: string]: ThreadStats.FeedbackStats }; + + /** + * `first_start_time` is the earliest trace start time in the thread (RFC3339). + * Populated when `FIRST_START_TIME` is selected. + */ + first_start_time?: string; + + /** + * `last_end_time` is the latest trace end time in the thread (RFC3339). Populated + * when `LAST_END_TIME` is selected. + */ + last_end_time?: string; + + /** + * `last_start_time` is the latest trace start time in the thread (RFC3339). + * Populated when `LAST_START_TIME` is selected. + */ + last_start_time?: string; + + /** + * `latency_p50_seconds` is the approximate p50 of trace latency across the thread, + * in seconds. Populated when `LATENCY_P50` is selected. + */ + latency_p50_seconds?: number; + + /** + * `latency_p99_seconds` is the approximate p99 of trace latency across the thread, + * in seconds. Populated when `LATENCY_P99` is selected. + */ + latency_p99_seconds?: number; + + /** + * `prompt_cost` is the sum of per-trace prompt costs across the thread, in USD. + * Populated when `PROMPT_COST` is selected. + */ + prompt_cost?: number; + + /** + * `prompt_cost_details` is the per-sub-category sum of prompt cost details across + * the thread. Populated when `PROMPT_COST_DETAILS` is selected. + */ + prompt_cost_details?: ThreadStats.PromptCostDetails; + + /** + * `prompt_token_details` is the per-sub-category sum of prompt token details + * across the thread. Populated when `PROMPT_TOKEN_DETAILS` is selected. + */ + prompt_token_details?: ThreadStats.PromptTokenDetails; + + /** + * `prompt_tokens` is the sum of per-trace prompt token counts across the thread. + * Populated when `PROMPT_TOKENS` is selected. + */ + prompt_tokens?: number; + + /** + * `total_cost` is the sum of per-trace total costs across the thread, in USD. + * Populated when `TOTAL_COST` is selected. + */ + total_cost?: number; + + /** + * `total_tokens` is the sum of per-trace total token counts across the thread. + * Populated when `TOTAL_TOKENS` is selected. + */ + total_tokens?: number; + + /** + * `turns` is the number of distinct traces (turns) in the thread. Populated when + * `TURNS` is selected. + */ + turns?: number; +} + +export namespace ThreadStats { + /** + * `completion_cost_details` is the per-sub-category sum of completion cost details + * across the thread. Populated when `COMPLETION_COST_DETAILS` is selected. + */ + export interface CompletionCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `completion_token_details` is the per-sub-category sum of completion token + * details across the thread. Populated when `COMPLETION_TOKEN_DETAILS` is + * selected. + */ + export interface CompletionTokenDetails { + /** + * `raw` maps each category name to its completion-token count. + */ + raw?: { [key: string]: number }; + } + + export interface FeedbackStats { + /** + * `avg` is the arithmetic mean of numeric feedback scores for this key on the run, + * or `null` when no numeric score has been recorded (for example purely + * categorical feedback). + */ + avg?: number; + + /** + * `comments` is a sample of human-readable comments attached to feedback points + * for this key, in no particular order. May be empty; is not exhaustive when many + * comments exist. + */ + comments?: Array; + + /** + * `contains_thread_feedback` is true when at least one feedback point for this key + * was submitted at the thread level (rather than at an individual run). Always + * false on responses that already describe a single run in isolation. + */ + contains_thread_feedback?: boolean; + + /** + * `errors` is the number of feedback points recorded as errors rather than + * successful scores (for example an automated evaluator that raised an exception). + * Defaults to 0 when no errors occurred. + */ + errors?: number; + + /** + * `max` is the largest numeric feedback score recorded for this key on the run, or + * `null` when no numeric score has been recorded. + */ + max?: number; + + /** + * `min` is the smallest numeric feedback score recorded for this key on the run, + * or `null` when no numeric score has been recorded. + */ + min?: number; + + /** + * `n` is the number of feedback points recorded for this key on the run. For + * numeric feedback this is the sample size behind `avg`, `min`, `max`, and + * `stdev`; for categorical feedback it is the sum of the `values` counts. + */ + n?: number; + + /** + * `sources` is a sample of feedback sources for this key. Each entry is either a + * plain string identifier (for example `"api"`, `"app"`, `"model"`) or a JSON + * object describing a synthetic source (for example + * `{"type": "__ls_composite_feedback"}` for a computed aggregate). Clients must + * tolerate both shapes. + */ + sources?: Array; + + /** + * `stdev` is the sample standard deviation of numeric feedback scores for this key + * on the run, or `null` when it cannot be computed (for example fewer than two + * numeric scores, or purely categorical feedback). + */ + stdev?: number; + + /** + * `values` is the distribution of categorical feedback labels for this key, + * mapping each label to its occurrence count. Empty (`{}`) for purely numeric + * feedback. + */ + values?: { [key: string]: number }; + } + + /** + * `prompt_cost_details` is the per-sub-category sum of prompt cost details across + * the thread. Populated when `PROMPT_COST_DETAILS` is selected. + */ + export interface PromptCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `prompt_token_details` is the per-sub-category sum of prompt token details + * across the thread. Populated when `PROMPT_TOKEN_DETAILS` is selected. + */ + export interface PromptTokenDetails { + /** + * `raw` maps each category name to its prompt-token count. + */ + raw?: { [key: string]: number }; + } +} + +export interface ThreadTrace { + /** + * `completion_cost` is the estimated USD cost for the completion. Omitted unless + * included in `selects`. + */ + completion_cost?: number; + + /** + * `completion_cost_details` is the USD cost breakdown for completion-side + * categories; per-category values are under `raw`. Omitted unless included in + * `selects`. + */ + completion_cost_details?: ThreadTrace.CompletionCostDetails; + + /** + * `completion_token_details` is the completion-side token breakdown by category; + * per-category counts are under `raw`. Omitted unless included in `selects`. + */ + completion_token_details?: ThreadTrace.CompletionTokenDetails; + + /** + * `completion_tokens` is the completion-side token count. Omitted unless included + * in `selects`. + */ + completion_tokens?: number; + + /** + * `end_time` is when the root run ended (RFC3339 date-time). JSON null if the run + * is still in progress. Omitted unless included in `selects`. + */ + end_time?: string; + + /** + * `error` is the full root run error message when the run failed. Omitted unless + * included in `selects`. + */ + error?: string; + + /** + * `error_preview` is a short error summary when the run failed. Omitted unless + * included in `selects`. + */ + error_preview?: string; + + /** + * `first_token_time` is when the first output token was produced (RFC3339 + * date-time), for streamed runs when that metadata exists. Omitted unless included + * in `selects`. + */ + first_token_time?: string; + + /** + * `inputs` is the full root run input payload. Omitted unless included in + * `selects`. + */ + inputs?: unknown; + + /** + * `inputs_preview` is a truncated text preview of inputs. Omitted unless included + * in `selects`. + */ + inputs_preview?: string; + + /** + * `latency` is wall-clock duration from start to end in seconds. Omitted unless + * included in `selects`. + */ + latency?: number; + + /** + * `name` is a human-readable label for the root run (for example the model name, + * function name, or step name chosen when the run was traced). Omitted unless + * included in `selects`. + */ + name?: string; + + /** + * `op` is a numeric code identifying the root run's `run_type` (for example LLM + * vs. tool vs. chain). Encoded as a number for compatibility with legacy clients; + * prefer the string `run_type` on `RunResponse` when available. Omitted unless + * included in `selects`. + */ + op?: number; + + /** + * `outputs` is the full root run output payload. Omitted unless included in + * `selects`. + */ + outputs?: unknown; + + /** + * `outputs_preview` is a truncated text preview of outputs. Omitted unless + * included in `selects`. + */ + outputs_preview?: string; + + /** + * `prompt_cost` is the estimated USD cost for the prompt. Omitted unless included + * in `selects`. + */ + prompt_cost?: number; + + /** + * `prompt_cost_details` is the USD cost breakdown for prompt-side categories; + * per-category values are under `raw`. Omitted unless included in `selects`. + */ + prompt_cost_details?: ThreadTrace.PromptCostDetails; + + /** + * `prompt_token_details` is the prompt-side token breakdown by category; + * per-category counts are under nested `raw`. Omitted unless included in + * `selects`. + */ + prompt_token_details?: ThreadTrace.PromptTokenDetails; + + /** + * `prompt_tokens` is the prompt-side token count. Omitted unless included in + * `selects`. + */ + prompt_tokens?: number; + + /** + * `start_time` is when the trace started (RFC3339 date-time). Omitted unless + * included in `selects`. + */ + start_time?: string; + + /** + * `thread_id` is the conversation thread UUID that contains this trace. Matches + * the `thread_id` path parameter of the request. Omitted unless included in + * `selects`. + */ + thread_id?: string; + + /** + * `total_cost` is the estimated total USD cost for the root run. Omitted unless + * included in `selects`. + */ + total_cost?: number; + + /** + * `total_tokens` is the total token count (prompt plus completion). Omitted unless + * included in `selects`. + */ + total_tokens?: number; + + /** + * `trace_id` is the UUID of this trace (the root run). Returned when `TRACE_ID` is + * in `selects`, or when `selects` is omitted entirely (sole fallback field). + */ + trace_id?: string; + + /** + * `turn_number` is the 1-based position in the whole thread, ordered by start_time + * then trace_id ascending, before filtering or pagination. Updates and deletions + * can change this position. Omitted unless TURN_NUMBER is included in `selects`. + */ + turn_number?: number; +} + +export namespace ThreadTrace { + /** + * `completion_cost_details` is the USD cost breakdown for completion-side + * categories; per-category values are under `raw`. Omitted unless included in + * `selects`. + */ + export interface CompletionCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `completion_token_details` is the completion-side token breakdown by category; + * per-category counts are under `raw`. Omitted unless included in `selects`. + */ + export interface CompletionTokenDetails { + /** + * `raw` maps each category name to its completion-token count. + */ + raw?: { [key: string]: number }; + } + + /** + * `prompt_cost_details` is the USD cost breakdown for prompt-side categories; + * per-category values are under `raw`. Omitted unless included in `selects`. + */ + export interface PromptCostDetails { + /** + * `raw` maps each category name to its estimated USD cost. + */ + raw?: { [key: string]: number }; + } + + /** + * `prompt_token_details` is the prompt-side token breakdown by category; + * per-category counts are under nested `raw`. Omitted unless included in + * `selects`. + */ + export interface PromptTokenDetails { + /** + * `raw` maps each category name to its prompt-token count. + */ + raw?: { [key: string]: number }; + } +} + +export interface ThreadAggregateStatsResponse { + /** + * `completion_cost` is the completion cost across matching traces in USD. + */ + completion_cost?: number; + + /** + * `completion_cost_details` contains completion-cost totals by category. + */ + completion_cost_details?: { [key: string]: number }; + + /** + * `completion_token_details` contains completion-token totals by category. + */ + completion_token_details?: { [key: string]: number }; + + /** + * `completion_tokens` is the sum of completion tokens across matching traces. + */ + completion_tokens?: number; + + /** + * `error_rate` is the fraction of matching traces that contain an error. + */ + error_rate?: number; + + /** + * `first_token_p50_seconds` is the approximate median time to first token in + * seconds. Populated when `FIRST_TOKEN_P50` is selected. + */ + first_token_p50_seconds?: number; + + /** + * `first_token_p99_seconds` is the approximate p99 time to first token in seconds. + * Populated when `FIRST_TOKEN_P99` is selected. + */ + first_token_p99_seconds?: number; + + /** + * `latency_p50_seconds` is the approximate median trace latency in seconds. + * Populated when `LATENCY_P50` is selected. + */ + latency_p50_seconds?: number; + + /** + * `latency_p99_seconds` is the approximate p99 trace latency in seconds. Populated + * when `LATENCY_P99` is selected. + */ + latency_p99_seconds?: number; + + /** + * `median_tokens` is the approximate median of total tokens across matching + * traces. Populated when `MEDIAN_TOKENS` is selected. + */ + median_tokens?: number; + + /** + * `prompt_cost` is the prompt cost across matching traces in USD. + */ + prompt_cost?: number; + + /** + * `prompt_cost_details` contains prompt-cost totals by category. + */ + prompt_cost_details?: { [key: string]: number }; + + /** + * `prompt_token_details` contains prompt-token totals by category. + */ + prompt_token_details?: { [key: string]: number }; + + /** + * `prompt_tokens` is the sum of prompt tokens across matching traces. + */ + prompt_tokens?: number; + + /** + * `streaming_rate` is the fraction of completed matching traces that streamed + * tokens. + */ + streaming_rate?: number; + + /** + * `thread_count` is the number of distinct threads matching the query. Populated + * when `THREAD_COUNT` is selected. + */ + thread_count?: number; + + /** + * `thread_feedback_stats` contains aggregate thread-level feedback statistics + * keyed by feedback key. Populated when `THREAD_FEEDBACK_STATS` is selected. + */ + thread_feedback_stats?: { [key: string]: ThreadAggregateStatsResponse.ThreadFeedbackStats }; + + /** + * `total_cost` is the total cost across matching traces in USD. + */ + total_cost?: number; + + /** + * `total_tokens` is the sum of all tokens across matching traces. + */ + total_tokens?: number; + + /** + * `trace_count` is the number of traces in the matching threads. Populated when + * `TRACE_COUNT` is selected. + */ + trace_count?: number; +} + +export namespace ThreadAggregateStatsResponse { + export interface ThreadFeedbackStats { + /** + * `avg` is the arithmetic mean of numeric feedback scores for this key on the run, + * or `null` when no numeric score has been recorded (for example purely + * categorical feedback). + */ + avg?: number; + + /** + * `comments` is a sample of human-readable comments attached to feedback points + * for this key, in no particular order. May be empty; is not exhaustive when many + * comments exist. + */ + comments?: Array; + + /** + * `contains_thread_feedback` is true when at least one feedback point for this key + * was submitted at the thread level (rather than at an individual run). Always + * false on responses that already describe a single run in isolation. + */ + contains_thread_feedback?: boolean; + + /** + * `errors` is the number of feedback points recorded as errors rather than + * successful scores (for example an automated evaluator that raised an exception). + * Defaults to 0 when no errors occurred. + */ + errors?: number; + + /** + * `max` is the largest numeric feedback score recorded for this key on the run, or + * `null` when no numeric score has been recorded. + */ + max?: number; + + /** + * `min` is the smallest numeric feedback score recorded for this key on the run, + * or `null` when no numeric score has been recorded. + */ + min?: number; + + /** + * `n` is the number of feedback points recorded for this key on the run. For + * numeric feedback this is the sample size behind `avg`, `min`, `max`, and + * `stdev`; for categorical feedback it is the sum of the `values` counts. + */ + n?: number; + + /** + * `sources` is a sample of feedback sources for this key. Each entry is either a + * plain string identifier (for example `"api"`, `"app"`, `"model"`) or a JSON + * object describing a synthetic source (for example + * `{"type": "__ls_composite_feedback"}` for a computed aggregate). Clients must + * tolerate both shapes. + */ + sources?: Array; + + /** + * `stdev` is the sample standard deviation of numeric feedback scores for this key + * on the run, or `null` when it cannot be computed (for example fewer than two + * numeric scores, or purely categorical feedback). + */ + stdev?: number; + + /** + * `values` is the distribution of categorical feedback labels for this key, + * mapping each label to its occurrence count. Empty (`{}`) for purely numeric + * feedback. + */ + values?: { [key: string]: number }; + } +} + +export interface ThreadAggregateStatsParams { + /** + * `project_id` is the tracing project UUID. + */ + project_id: string; + + /** + * `select` lists the aggregate statistics to compute and return. At least one + * value is required. + */ + select: Array< + | 'THREAD_COUNT' + | 'TRACE_COUNT' + | 'TOTAL_TOKENS' + | 'TOTAL_COST' + | 'ERROR_RATE' + | 'STREAMING_RATE' + | 'LATENCY_P50' + | 'LATENCY_P99' + | 'MEDIAN_TOKENS' + | 'FIRST_TOKEN_P50' + | 'FIRST_TOKEN_P99' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'THREAD_FEEDBACK_STATS' + >; + + /** + * `filter` is a deprecated, unscoped LangSmith filter expression evaluated against + * trace root runs. Kept for compatibility with deployments that serve this + * endpoint via the legacy ClickHouse backend (no SmithDB query service + * configured); prefer `trace_filter`, `tree_filter`, or `thread_filter` otherwise, + * since those require SmithDB. + */ + filter?: string; + + /** + * `max_start_time` is the exclusive upper bound on thread activity (RFC3339 + * date-time). Defaults to now (UTC) when omitted. + */ + max_start_time?: string; + + /** + * `min_start_time` is the inclusive lower bound on thread activity (RFC3339 + * date-time). Defaults to 1 day before now (UTC) when omitted. + */ + min_start_time?: string; + + /** + * `thread_filter` narrows eligible threads using a LangSmith filter expression + * evaluated against the complete thread summary. + */ + thread_filter?: string; + + /** + * `trace_filter` narrows eligible threads to those containing a trace whose root + * run matches this LangSmith filter expression. + */ + trace_filter?: string; + + /** + * `tree_filter` narrows eligible threads to those containing a matching run + * anywhere in a trace tree. + */ + tree_filter?: string; +} + +export interface ThreadListTracesParams extends ItemsCursorGetPaginationParams { + /** + * `project_id` is the tracing project UUID (required). + */ + project_id: string; + + /** + * `filter` narrows which traces are returned for this thread, using a LangSmith + * filter expression evaluated against each root trace run. For example: eq(status, + * "success") or has(tags, "production"). See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + filter?: string; + + /** + * `selects` lists which properties to include on each returned trace (repeatable + * query parameter). Accepts any value of the `ThreadTraceSelectField` enum. + * Properties not listed are omitted from each trace object; `trace_id` is always + * returned. + */ + selects?: Array< + | 'THREAD_ID' + | 'TRACE_ID' + | 'OP' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'TOTAL_TOKENS' + | 'START_TIME' + | 'END_TIME' + | 'LATENCY' + | 'FIRST_TOKEN_TIME' + | 'INPUTS_PREVIEW' + | 'OUTPUTS_PREVIEW' + | 'INPUTS' + | 'OUTPUTS' + | 'ERROR' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'TOTAL_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'NAME' + | 'ERROR_PREVIEW' + | 'TURN_NUMBER' + >; + + /** + * `trace_filter` narrows traces by applying a LangSmith filter expression to each + * trace's root run. + */ + trace_filter?: string; + + /** + * `tree_filter` narrows traces to those containing at least one run that matches + * the LangSmith filter expression. + */ + tree_filter?: string; +} + +export interface ThreadQueryParams extends ItemsCursorPostPaginationParams { + /** + * Body param: `filter` narrows which threads are returned, using a LangSmith + * filter expression evaluated against each thread's root run. For example: + * has(tags, "production") or eq(status, "error"). See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + filter?: string; + + /** + * Body param: `max_start_time` is the exclusive upper bound on thread activity + * (RFC3339 date-time). Defaults to now (UTC) when omitted. + */ + max_start_time?: string; + + /** + * Body param: `min_start_time` is the inclusive lower bound on thread activity + * (RFC3339 date-time). Defaults to 1 day before now (UTC) when omitted. + */ + min_start_time?: string; + + /** + * Body param: `project_id` is the tracing project UUID. + */ + project_id?: string; + + /** + * Body param: `thread_filter` narrows results using a LangSmith filter expression + * evaluated against each complete thread summary. Self-hosted deployments require + * LangSmith v0.17 or later; unsupported deployments return 501. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + thread_filter?: string; + + /** + * Body param: `trace_filter` narrows results to threads containing at least one + * trace whose root run matches this LangSmith filter expression. Trace-level + * aggregate fields are evaluated using the complete trace summary. Self-hosted + * deployments require LangSmith v0.17 or later; unsupported deployments + * return 501. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + trace_filter?: string; + + /** + * Body param: `tree_filter` narrows results to threads containing at least one + * trace with a matching run anywhere in its run tree. Self-hosted deployments + * require LangSmith v0.17 or later; unsupported deployments return 501. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + tree_filter?: string; + + /** + * Header param: application/json or text/event-stream + */ + Accept?: string; +} + +export interface ThreadStatsParams { + /** + * `selects` lists which aggregate stats to compute and return (repeatable query + * parameter). At least one value is required. Accepts any value of + * `SingleThreadStatsSelectField`. + */ + selects: Array< + | 'TURNS' + | 'FIRST_START_TIME' + | 'LAST_START_TIME' + | 'LAST_END_TIME' + | 'LATENCY_P50' + | 'LATENCY_P99' + | 'PROMPT_TOKENS' + | 'PROMPT_COST' + | 'COMPLETION_TOKENS' + | 'COMPLETION_COST' + | 'TOTAL_TOKENS' + | 'TOTAL_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'FEEDBACK_STATS' + >; + + /** + * `session_id` is the tracing project (session) UUID (required). + */ + session_id: string; + + /** + * `filter` narrows which of the thread's traces are aggregated, using a LangSmith + * filter expression. For example: lt(start_time, "2025-01-01T00:00:00Z") or + * eq(trace_id, "0190a1b2-c3d4-7ef0-a5b6-6ea3a82e9328"). See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + filter?: string; +} + +Threads.Share = Share; + +export declare namespace Threads { + export { + type Thread as Thread, + type ThreadStats as ThreadStats, + type ThreadTrace as ThreadTrace, + type ThreadAggregateStatsResponse as ThreadAggregateStatsResponse, + type ThreadTracesItemsCursorGetPagination as ThreadTracesItemsCursorGetPagination, + type ThreadsItemsCursorPostPagination as ThreadsItemsCursorPostPagination, + type ThreadAggregateStatsParams as ThreadAggregateStatsParams, + type ThreadListTracesParams as ThreadListTracesParams, + type ThreadQueryParams as ThreadQueryParams, + type ThreadStatsParams as ThreadStatsParams, + }; + + export { + Share as Share, + type ShareCreateResponse as ShareCreateResponse, + type ShareRetrieveResponse as ShareRetrieveResponse, + type ShareCreateParams as ShareCreateParams, + type ShareRetrieveParams as ShareRetrieveParams, + type ShareDeleteParams as ShareDeleteParams, + }; +} diff --git a/src/resources/traces.ts b/src/resources/traces.ts new file mode 100644 index 0000000..c108413 --- /dev/null +++ b/src/resources/traces.ts @@ -0,0 +1,270 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIResource } from '../core/resource'; +import * as RunsAPI from './runs/runs'; +import { APIPromise } from '../core/api-promise'; +import { + ItemsCursorPostPagination, + type ItemsCursorPostPaginationParams, + PagePromise, +} from '../core/pagination'; +import { buildHeaders } from '../internal/headers'; +import { RequestOptions } from '../internal/request-options'; +import { path } from '../internal/utils/path'; + +export class Traces extends APIResource { + /** + * Returns runs for a trace ID within min/max start time. Optional `filter`; + * repeatable `selects` to select fields to return. + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * const response = await client.traces.listRuns( + * '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + * { project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e' }, + * ); + * ``` + */ + listRuns( + traceID: string, + params: TraceListRunsParams, + options?: RequestOptions, + ): APIPromise { + const { Accept, ...query } = params; + return this._client.get(path`/api/v2/traces/${traceID}/runs`, { + query, + ...options, + headers: buildHeaders([{ ...(Accept != null ? { Accept: Accept } : undefined) }, options?.headers]), + }); + } + + /** + * Returns a paginated list of traces (root runs) for a single tracing project. + * Each item carries the trace's root run plus optional trace-wide aggregates + * (`total_tokens`, `total_cost`, `first_token_time`) under `trace_aggregates`, so + * clients never have to merge by `trace_id`. + * + * Traces are scanned within a `start_time` window: `min_start_time` defaults to 24 + * hours before the request, `max_start_time` defaults to the request time. Set + * either explicitly to widen or narrow the window. + * + * Supports filters (`trace_filter`, `tree_filter`), cursor pagination (`cursor`), + * and field projection (`selects`). + * + * Self-hosted deployments require LangSmith `v0.16` or later. + * + * @example + * ```ts + * // Automatically fetches more pages as needed. + * for await (const trace of client.traces.query()) { + * // ... + * } + * ``` + */ + query( + body: TraceQueryParams, + options?: RequestOptions, + ): PagePromise { + return this._client.getAPIList('/api/v2/traces/query', ItemsCursorPostPagination, { + body, + method: 'post', + ...options, + }); + } +} + +export type TracesItemsCursorPostPagination = ItemsCursorPostPagination; + +export interface Trace { + /** + * `root_run` is the trace's root run. Which properties are populated is controlled + * by `selects` in the request. + */ + root_run?: RunsAPI.Run; + + /** + * `trace_aggregates` carries trace-wide aggregate metrics. Omitted when no + * aggregate field was selected, or `null` (then later filled) on the streaming + * wire while the aggregate values are still being computed. + */ + trace_aggregates?: TraceAggregates; +} + +export interface TraceAggregates { + /** + * `first_token_time` is when the first output token was produced anywhere in the + * trace (RFC3339), when recorded. + */ + first_token_time?: string; + + /** + * `total_cost` is total estimated USD cost across every run in the trace. + */ + total_cost?: number; + + /** + * `total_tokens` is prompt plus completion tokens summed across every run in the + * trace. + */ + total_tokens?: number; +} + +export interface TraceListRunsResponse { + /** + * `items` lists runs in the trace for the requested time window, in `start_time` + * order. + */ + items?: Array; +} + +export interface TraceListRunsParams { + /** + * Query param: `project_id` is the UUID of the tracing project that owns the + * trace. + */ + project_id: string; + + /** + * Query param: `filter` narrows which runs within this trace are returned, using a + * LangSmith filter expression evaluated against each run. For example: + * `eq(run_type, "llm")` for LLM runs only, or `eq(status, "error")` for failed + * runs. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + filter?: string; + + /** + * Query param: `max_start_time` is the optional inclusive upper bound for run + * `start_time` (RFC3339 date-time). Required together with `min_start_time`. + */ + max_start_time?: string; + + /** + * Query param: `min_start_time` is the optional inclusive lower bound for run + * `start_time` (RFC3339 date-time). Required together with `max_start_time`. + */ + min_start_time?: string; + + /** + * Query param: `selects` lists which properties to include on each returned run + * (repeatable query parameter). Accepts any value of the `RunSelectField` enum. If + * omitted, only `id` is returned. + */ + selects?: Array< + | 'ID' + | 'NAME' + | 'RUN_TYPE' + | 'STATUS' + | 'START_TIME' + | 'END_TIME' + | 'LATENCY_SECONDS' + | 'FIRST_TOKEN_TIME' + | 'ERROR' + | 'ERROR_PREVIEW' + | 'EXTRA' + | 'METADATA' + | 'EVENTS' + | 'INPUTS' + | 'INPUTS_PREVIEW' + | 'OUTPUTS' + | 'OUTPUTS_PREVIEW' + | 'MANIFEST' + | 'PARENT_RUN_IDS' + | 'PROJECT_ID' + | 'TRACE_ID' + | 'THREAD_ID' + | 'DOTTED_ORDER' + | 'IS_ROOT' + | 'REFERENCE_EXAMPLE_ID' + | 'REFERENCE_DATASET_ID' + | 'TOTAL_TOKENS' + | 'PROMPT_TOKENS' + | 'COMPLETION_TOKENS' + | 'TOTAL_COST' + | 'PROMPT_COST' + | 'COMPLETION_COST' + | 'PROMPT_TOKEN_DETAILS' + | 'COMPLETION_TOKEN_DETAILS' + | 'PROMPT_COST_DETAILS' + | 'COMPLETION_COST_DETAILS' + | 'PRICE_MODEL_ID' + | 'TAGS' + | 'APP_PATH' + | 'ATTACHMENTS' + | 'THREAD_EVALUATION_TIME' + | 'IS_IN_DATASET' + | 'LAST_QUEUED_AT' + | 'SHARE_URL' + | 'FEEDBACK_STATS' + | 'LS_USER_ID' + >; + + /** + * Header param: application/json + */ + Accept?: string; +} + +export interface TraceQueryParams extends ItemsCursorPostPaginationParams { + /** + * `max_start_time` is the exclusive upper bound for the root-run start time scan + * (RFC3339). Defaults to the request time when omitted. + */ + max_start_time?: string; + + /** + * `min_start_time` is the inclusive lower bound for the root-run start time scan + * (RFC3339). Defaults to 24 hours before the request when omitted. + */ + min_start_time?: string; + + /** + * `project_id` is the UUID of the tracing project that owns the traces. Required. + */ + project_id?: string; + + /** + * `selects` lists which properties to include on each returned trace. Properties + * listed here are routed to the appropriate sub-object on each item: + * `total_tokens`, `total_cost`, and `first_token_time` appear under + * `trace_aggregates`; everything else appears under `root_run`. If omitted, only + * `id` is returned on `root_run`. + */ + selects?: Array; + + /** + * `trace_filter` narrows results to traces whose root run matches this LangSmith + * filter expression. This filter targets root runs only — `is_root = true` is + * implied. See + * https://docs.langchain.com/langsmith/trace-query-syntax#filter-query-language + * for syntax. + */ + trace_filter?: string; + + /** + * `trace_ids` is an optional fast-path restriction to a known set of trace UUIDs. + * Equivalent in result to including each UUID in a `trace_filter`, but more + * efficient at scale. + */ + trace_ids?: Array; + + /** + * `tree_filter` narrows results to traces containing at least one run anywhere in + * the run tree (root or descendant) that matches this LangSmith filter expression. + */ + tree_filter?: string; +} + +export declare namespace Traces { + export { + type Trace as Trace, + type TraceAggregates as TraceAggregates, + type TraceListRunsResponse as TraceListRunsResponse, + type TracesItemsCursorPostPagination as TracesItemsCursorPostPagination, + type TraceListRunsParams as TraceListRunsParams, + type TraceQueryParams as TraceQueryParams, + }; +} diff --git a/src/run_trees.ts b/src/run_trees.ts new file mode 100644 index 0000000..6903060 --- /dev/null +++ b/src/run_trees.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/run_trees'; diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 index 0000000..69fdc2a --- /dev/null +++ b/src/sandbox.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/sandbox/index'; diff --git a/src/schemas.ts b/src/schemas.ts new file mode 100644 index 0000000..69b514e --- /dev/null +++ b/src/schemas.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/schemas'; diff --git a/src/singletons/traceable.ts b/src/singletons/traceable.ts new file mode 100644 index 0000000..4247902 --- /dev/null +++ b/src/singletons/traceable.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/singletons/traceable'; diff --git a/src/traceable.ts b/src/traceable.ts new file mode 100644 index 0000000..62896e3 --- /dev/null +++ b/src/traceable.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/traceable'; diff --git a/src/uploads.ts b/src/uploads.ts new file mode 100644 index 0000000..b2ef647 --- /dev/null +++ b/src/uploads.ts @@ -0,0 +1,2 @@ +/** @deprecated Import from ./core/uploads instead */ +export * from './core/uploads'; diff --git a/src/utils/jestlike.ts b/src/utils/jestlike.ts new file mode 100644 index 0000000..8ac6d44 --- /dev/null +++ b/src/utils/jestlike.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/utils/jestlike/index'; diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..522bada --- /dev/null +++ b/src/version.ts @@ -0,0 +1 @@ +export const VERSION = '0.10.4'; // x-release-please-version diff --git a/src/vitest.mts b/src/vitest.mts new file mode 100644 index 0000000..471d0d8 --- /dev/null +++ b/src/vitest.mts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/vitest/index.mjs'; diff --git a/src/vitest/reporter.mts b/src/vitest/reporter.mts new file mode 100644 index 0000000..243223b --- /dev/null +++ b/src/vitest/reporter.mts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export { default } from '../lib/vitest/reporter.mjs'; diff --git a/src/wrappers.ts b/src/wrappers.ts new file mode 100644 index 0000000..4103faa --- /dev/null +++ b/src/wrappers.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from './lib/wrappers/index'; diff --git a/src/wrappers/anthropic.ts b/src/wrappers/anthropic.ts new file mode 100644 index 0000000..58c7226 --- /dev/null +++ b/src/wrappers/anthropic.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/wrappers/anthropic'; diff --git a/src/wrappers/gemini.ts b/src/wrappers/gemini.ts new file mode 100644 index 0000000..85676cb --- /dev/null +++ b/src/wrappers/gemini.ts @@ -0,0 +1,3 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/wrappers/gemini'; +export { default } from '../lib/wrappers/gemini'; diff --git a/src/wrappers/openai.ts b/src/wrappers/openai.ts new file mode 100644 index 0000000..9a00f39 --- /dev/null +++ b/src/wrappers/openai.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/wrappers/openai'; diff --git a/src/wrappers/openai_agents.ts b/src/wrappers/openai_agents.ts new file mode 100644 index 0000000..1ece1da --- /dev/null +++ b/src/wrappers/openai_agents.ts @@ -0,0 +1,2 @@ +// Entrypoint shim: `langsmith/` -> hand-written code in src/lib (kept from langsmith-sdk). +export * from '../lib/wrappers/openai_agents'; diff --git a/tests/api-resources/annotation-queues/annotation-queues.test.ts b/tests/api-resources/annotation-queues/annotation-queues.test.ts new file mode 100644 index 0000000..c6352ac --- /dev/null +++ b/tests/api-resources/annotation-queues/annotation-queues.test.ts @@ -0,0 +1,271 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource annotationQueues', () => { + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.annotationQueues.retrieve('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update', async () => { + const responsePromise = client.annotationQueues.update('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.annotationQueues.delete('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('annotationQueues: only required params', async () => { + const responsePromise = client.annotationQueues.annotationQueues({ name: 'name' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('annotationQueues: required and optional params', async () => { + const response = await client.annotationQueues.annotationQueues({ + name: 'name', + id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + created_at: '2019-12-27T18:11:19.117Z', + default_dataset: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + description: 'description', + enable_reservations: true, + metadata: { foo: 'bar' }, + num_reviewers_per_item: 0, + reservation_minutes: 0, + reviewer_access_mode: 'reviewer_access_mode', + rubric_instructions: 'rubric_instructions', + rubric_items: [ + { + feedback_key: 'feedback_key', + description: 'description', + is_assertion: true, + is_required: true, + regex_validator: 'string', + score_descriptions: { foo: 'string' }, + value_descriptions: { foo: 'string' }, + }, + ], + session_ids: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + updated_at: '2019-12-27T18:11:19.117Z', + }); + }); + + // Mock server tests are disabled + test.skip('createRunStatus', async () => { + const responsePromise = client.annotationQueues.createRunStatus( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + {}, + ); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('export', async () => { + const responsePromise = client.annotationQueues.export('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('populate: only required params', async () => { + const responsePromise = client.annotationQueues.populate({ + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + session_ids: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('populate: required and optional params', async () => { + const response = await client.annotationQueues.populate({ + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + session_ids: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + extend_trace_retention: true, + }); + }); + + // Mock server tests are disabled + test.skip('retrieveAnnotationQueues', async () => { + const responsePromise = client.annotationQueues.retrieveAnnotationQueues(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveAnnotationQueues: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.annotationQueues.retrieveAnnotationQueues( + { + assigned_to_me: true, + dataset_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + ids: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + limit: 1, + name: 'name', + name_contains: 'name_contains', + offset: 0, + queue_type: 'single', + sort_by: 'sort_by', + sort_by_desc: true, + tag_value_id: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('retrieveQueues', async () => { + const responsePromise = client.annotationQueues.retrieveQueues('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveRun: only required params', async () => { + const responsePromise = client.annotationQueues.retrieveRun(0, { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveRun: required and optional params', async () => { + const response = await client.annotationQueues.retrieveRun(0, { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + include_extra: true, + }); + }); + + // Mock server tests are disabled + test.skip('retrieveSize', async () => { + const responsePromise = client.annotationQueues.retrieveSize('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveSize: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.annotationQueues.retrieveSize( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + { status: 'needs_my_review' }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('retrieveTotalArchived', async () => { + const responsePromise = client.annotationQueues.retrieveTotalArchived( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + ); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveTotalArchived: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.annotationQueues.retrieveTotalArchived( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + { end_time: '2019-12-27T18:11:19.117Z', start_time: '2019-12-27T18:11:19.117Z' }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('retrieveTotalSize', async () => { + const responsePromise = client.annotationQueues.retrieveTotalSize('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/annotation-queues/items.test.ts b/tests/api-resources/annotation-queues/items.test.ts new file mode 100644 index 0000000..02572a8 --- /dev/null +++ b/tests/api-resources/annotation-queues/items.test.ts @@ -0,0 +1,137 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource items', () => { + // Mock server tests are disabled + test.skip('create', async () => { + const responsePromise = client.annotationQueues.items.create('queue_id', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update: only required params', async () => { + const responsePromise = client.annotationQueues.items.update('item_id', { queue_id: 'queue_id' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update: required and optional params', async () => { + const response = await client.annotationQueues.items.update('item_id', { + queue_id: 'queue_id', + added_at: '2019-12-27T18:11:19.117Z', + last_reviewed_time: '2019-12-27T18:11:19.117Z', + }); + }); + + // Mock server tests are disabled + test.skip('list: only required params', async () => { + const responsePromise = client.annotationQueues.items.list('queue_id', { status: 'needs_my_review' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: required and optional params', async () => { + const response = await client.annotationQueues.items.list('queue_id', { + status: 'needs_my_review', + cursor: 'cursor', + direction: 'forward', + item_type: 'RUN', + max_start_time: '2019-12-27T18:11:19.117Z', + min_start_time: '2019-12-27T18:11:19.117Z', + page_size: 0, + }); + }); + + // Mock server tests are disabled + test.skip('createStatus', async () => { + const responsePromise = client.annotationQueues.items.createStatus('queue_item_id', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('deleteAll', async () => { + const responsePromise = client.annotationQueues.items.deleteAll('queue_id', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveCount: only required params', async () => { + const responsePromise = client.annotationQueues.items.retrieveCount('queue_id', { status: 'status' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveCount: required and optional params', async () => { + const response = await client.annotationQueues.items.retrieveCount('queue_id', { + status: 'status', + end_time: 'end_time', + max_start_time: '2019-12-27T18:11:19.117Z', + min_start_time: '2019-12-27T18:11:19.117Z', + start_time: 'start_time', + }); + }); + + // Mock server tests are disabled + test.skip('retrievePlacement: only required params', async () => { + const responsePromise = client.annotationQueues.items.retrievePlacement('item_id', { + queue_id: 'queue_id', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrievePlacement: required and optional params', async () => { + const response = await client.annotationQueues.items.retrievePlacement('item_id', { + queue_id: 'queue_id', + }); + }); +}); diff --git a/tests/api-resources/annotation-queues/runs.test.ts b/tests/api-resources/annotation-queues/runs.test.ts new file mode 100644 index 0000000..93ded37 --- /dev/null +++ b/tests/api-resources/annotation-queues/runs.test.ts @@ -0,0 +1,157 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource runs', () => { + // Mock server tests are disabled + test.skip('create: only required params', async () => { + const responsePromise = client.annotationQueues.runs.create('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + body: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('create: required and optional params', async () => { + const response = await client.annotationQueues.runs.create('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + body: ['182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'], + extend_trace_retention: true, + }); + }); + + // Mock server tests are disabled + test.skip('update: only required params', async () => { + const responsePromise = client.annotationQueues.runs.update('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update: required and optional params', async () => { + const response = await client.annotationQueues.runs.update('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + added_at: '2019-12-27T18:11:19.117Z', + last_reviewed_time: '2019-12-27T18:11:19.117Z', + }); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.annotationQueues.runs.list('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.annotationQueues.runs.list( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + { + archived: true, + include_stats: true, + limit: 1, + offset: 0, + status: 'needs_my_review', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('createByKey: only required params', async () => { + const responsePromise = client.annotationQueues.runs.createByKey('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + body: [ + { + run_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + session_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + start_time: '2019-12-27T18:11:19.117Z', + }, + ], + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('createByKey: required and optional params', async () => { + const response = await client.annotationQueues.runs.createByKey('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + body: [ + { + run_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + session_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + start_time: '2019-12-27T18:11:19.117Z', + source_proposed_example_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }, + ], + extend_trace_retention: true, + }); + }); + + // Mock server tests are disabled + test.skip('deleteAll', async () => { + const responsePromise = client.annotationQueues.runs.deleteAll( + '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + {}, + ); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('deleteQueue: only required params', async () => { + const responsePromise = client.annotationQueues.runs.deleteQueue('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('deleteQueue: required and optional params', async () => { + const response = await client.annotationQueues.runs.deleteQueue('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + queue_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + }); +}); diff --git a/tests/api-resources/datasets/examples.test.ts b/tests/api-resources/datasets/examples.test.ts new file mode 100644 index 0000000..bf1ffd1 --- /dev/null +++ b/tests/api-resources/datasets/examples.test.ts @@ -0,0 +1,32 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource examples', () => { + // Mock server tests are disabled + test.skip('delete: only required params', async () => { + const responsePromise = client.datasets.examples.delete('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + dataset_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('delete: required and optional params', async () => { + const response = await client.datasets.examples.delete('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + dataset_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + }); +}); diff --git a/tests/api-resources/datasets/experiment-runs.test.ts b/tests/api-resources/datasets/experiment-runs.test.ts new file mode 100644 index 0000000..28887df --- /dev/null +++ b/tests/api-resources/datasets/experiment-runs.test.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource experimentRuns', () => { + // Mock server tests are disabled + test.skip('query', async () => { + const responsePromise = client.datasets.experimentRuns.query('dataset_id', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/fleet/threads.test.ts b/tests/api-resources/fleet/threads.test.ts new file mode 100644 index 0000000..d26c3fe --- /dev/null +++ b/tests/api-resources/fleet/threads.test.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource threads', () => { + // Mock server tests are disabled + test.skip('activateSandbox', async () => { + const responsePromise = client.fleet.threads.activateSandbox('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/info.test.ts b/tests/api-resources/info.test.ts new file mode 100644 index 0000000..a14a065 --- /dev/null +++ b/tests/api-resources/info.test.ts @@ -0,0 +1,23 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource info', () => { + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.info.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/issues.test.ts b/tests/api-resources/issues.test.ts new file mode 100644 index 0000000..f1f1f14 --- /dev/null +++ b/tests/api-resources/issues.test.ts @@ -0,0 +1,68 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource issues', () => { + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.issues.retrieve('id'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieve: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.issues.retrieve('id', { include_linear_context: true }, { path: '/_stainless_unknown_path' }), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.issues.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.issues.list( + { + activity: ['fixing'], + limit: 0, + offset: 0, + session_id: 'session_id', + session_name: 'session_name', + severity: 0, + severity_exact: [0], + sort_by: 'default', + status: 'open', + status_first: true, + tag: 'tag', + trace_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + updated_at: 'updated_at', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); +}); diff --git a/tests/api-resources/online-evaluators.test.ts b/tests/api-resources/online-evaluators.test.ts new file mode 100644 index 0000000..7dca9bd --- /dev/null +++ b/tests/api-resources/online-evaluators.test.ts @@ -0,0 +1,152 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource onlineEvaluators', () => { + // Mock server tests are disabled + test.skip('create', async () => { + const responsePromise = client.onlineEvaluators.create({}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.onlineEvaluators.retrieve('evaluator_id'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update', async () => { + const responsePromise = client.onlineEvaluators.update('evaluator_id', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.onlineEvaluators.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.onlineEvaluators.list( + { + agent_id: 'agent_id', + feedback_key: 'feedback_key', + limit: 0, + name_contains: 'name_contains', + offset: 0, + resource_id: ['string'], + sort_by: 'sort_by', + sort_by_desc: true, + tag_value_id: ['string'], + type: 'type', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.onlineEvaluators.delete('evaluator_id'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('delete: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.onlineEvaluators.delete( + 'evaluator_id', + { delete_run_rules: true }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('bulkDelete: only required params', async () => { + const responsePromise = client.onlineEvaluators.bulkDelete({ evaluator_ids: ['string'] }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('bulkDelete: required and optional params', async () => { + const response = await client.onlineEvaluators.bulkDelete({ + evaluator_ids: ['string'], + delete_run_rules: true, + }); + }); + + // Mock server tests are disabled + test.skip('spend: only required params', async () => { + const responsePromise = client.onlineEvaluators.spend({ period_start: 'period_start' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('spend: required and optional params', async () => { + const response = await client.onlineEvaluators.spend({ + period_start: 'period_start', + dataset_id: 'dataset_id', + evaluator_id: 'evaluator_id', + feedback_key: 'feedback_key', + group_by: 'group_by', + resource_id: ['string'], + session_id: 'session_id', + tag_value_id: ['string'], + type: 'type', + }); + }); +}); diff --git a/tests/api-resources/product-feedback.test.ts b/tests/api-resources/product-feedback.test.ts new file mode 100644 index 0000000..3b83e0a --- /dev/null +++ b/tests/api-resources/product-feedback.test.ts @@ -0,0 +1,54 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource productFeedback', () => { + // Mock server tests are disabled + test.skip('create: only required params', async () => { + const responsePromise = client.productFeedback.create({ + category: 'BUG', + message: 'x', + source: 'LANGSMITH_CLI', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('create: required and optional params', async () => { + const response = await client.productFeedback.create({ + category: 'BUG', + message: 'x', + source: 'LANGSMITH_CLI', + client: { + architecture: 'architecture', + os: 'os', + version: 'version', + }, + 'Idempotency-Key': 'Idempotency-Key', + }); + }); + + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.productFeedback.retrieve('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/public/runs.test.ts b/tests/api-resources/public/runs.test.ts new file mode 100644 index 0000000..45e63d9 --- /dev/null +++ b/tests/api-resources/public/runs.test.ts @@ -0,0 +1,49 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource runs', () => { + // Mock server tests are disabled + test.skip('retrieve: only required params', async () => { + const responsePromise = client.public.runs.retrieve('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + share_token: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + selects: ['string'], + start_time: '2019-12-27T18:11:19.117Z', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieve: required and optional params', async () => { + const response = await client.public.runs.retrieve('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + share_token: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + selects: ['string'], + start_time: '2019-12-27T18:11:19.117Z', + Accept: 'Accept', + }); + }); + + // Mock server tests are disabled + test.skip('query', async () => { + const responsePromise = client.public.runs.query('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/runs/runs.test.ts b/tests/api-resources/runs/runs.test.ts new file mode 100644 index 0000000..8ca6194 --- /dev/null +++ b/tests/api-resources/runs/runs.test.ts @@ -0,0 +1,71 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource runs', () => { + // Mock server tests are disabled + test.skip('getURL: only required params', async () => { + const responsePromise = client.runs.getURL('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: 'project_id', + trace_id: 'trace_id', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('getURL: required and optional params', async () => { + const response = await client.runs.getURL('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: 'project_id', + trace_id: 'trace_id', + start_time: 'start_time', + }); + }); + + // Mock server tests are disabled + test.skip('queryV2', async () => { + const responsePromise = client.runs.queryV2({}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveV2: only required params', async () => { + const responsePromise = client.runs.retrieveV2('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveV2: required and optional params', async () => { + const response = await client.runs.retrieveV2('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + selects: ['ID'], + start_time: '2019-12-27T18:11:19.117Z', + Accept: 'Accept', + }); + }); +}); diff --git a/tests/api-resources/runs/share.test.ts b/tests/api-resources/runs/share.test.ts new file mode 100644 index 0000000..a9ac66f --- /dev/null +++ b/tests/api-resources/runs/share.test.ts @@ -0,0 +1,35 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource share', () => { + // Mock server tests are disabled + test.skip('create', async () => { + const responsePromise = client.runs.share.create('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.runs.share.delete('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/sandboxes/boxes.test.ts b/tests/api-resources/sandboxes/boxes.test.ts new file mode 100644 index 0000000..5f146e7 --- /dev/null +++ b/tests/api-resources/sandboxes/boxes.test.ts @@ -0,0 +1,243 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource boxes', () => { + // Mock server tests are disabled + test.skip('create', async () => { + const responsePromise = client.sandboxes.boxes.create({}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.sandboxes.boxes.retrieve('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update', async () => { + const responsePromise = client.sandboxes.boxes.update('name', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.sandboxes.boxes.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sandboxes.boxes.list( + { + created_by: 'created_by', + cursor: 'cursor', + label: ['string'], + limit: 0, + name_contains: 'name_contains', + offset: 0, + page_size: 0, + sort_by: 'sort_by', + sort_direction: 'sort_direction', + sort_order: 'sort_order', + status: 'status', + tag_value_id: ['string'], + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.sandboxes.boxes.delete('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('createSnapshot: only required params', async () => { + const responsePromise = client.sandboxes.boxes.createSnapshot('name', { name: 'name' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('createSnapshot: required and optional params', async () => { + const response = await client.sandboxes.boxes.createSnapshot('name', { + name: 'name', + checkpoint: 'checkpoint', + description: 'description', + docker_image: 'docker_image', + fs_capacity_bytes: 0, + include_memory: true, + labels: { foo: 'string' }, + run_config: { + env_vars: { foo: 'string' }, + user: 'user', + work_dir: 'work_dir', + }, + tag: 'tag', + }); + }); + + // Mock server tests are disabled + test.skip('deleteServiceURL', async () => { + const responsePromise = client.sandboxes.boxes.deleteServiceURL('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('deleteServiceURL: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sandboxes.boxes.deleteServiceURL('name', { port: 0 }, { path: '/_stainless_unknown_path' }), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('generateDownloadURL: only required params', async () => { + const responsePromise = client.sandboxes.boxes.generateDownloadURL('name', { path: 'path' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('generateDownloadURL: required and optional params', async () => { + const response = await client.sandboxes.boxes.generateDownloadURL('name', { + path: 'path', + content_disposition: 'content_disposition', + content_type: 'content_type', + csp_sandbox: true, + csp_sandbox_flags: ['allow-downloads'], + csp_source_bundles: ['cdnjs'], + expires_in_seconds: 0, + }); + }); + + // Mock server tests are disabled + test.skip('generateServiceURL', async () => { + const responsePromise = client.sandboxes.boxes.generateServiceURL('name', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('getStatus', async () => { + const responsePromise = client.sandboxes.boxes.getStatus('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('listServiceURLs', async () => { + const responsePromise = client.sandboxes.boxes.listServiceURLs('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('listServiceURLs: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sandboxes.boxes.listServiceURLs( + 'name', + { cursor: 'cursor', page_size: 0 }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('start', async () => { + const responsePromise = client.sandboxes.boxes.start('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('stop', async () => { + const responsePromise = client.sandboxes.boxes.stop('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/sandboxes/registries.test.ts b/tests/api-resources/sandboxes/registries.test.ts new file mode 100644 index 0000000..91aebaa --- /dev/null +++ b/tests/api-resources/sandboxes/registries.test.ts @@ -0,0 +1,98 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource registries', () => { + // Mock server tests are disabled + test.skip('create: only required params', async () => { + const responsePromise = client.sandboxes.registries.create({ name: 'name', url: 'url' }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('create: required and optional params', async () => { + const response = await client.sandboxes.registries.create({ + name: 'name', + url: 'url', + auth_type: 'DOCKER_CONFIG', + aws_role_arn: 'aws_role_arn', + password: 'password', + username: 'username', + }); + }); + + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.sandboxes.registries.retrieve('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('update', async () => { + const responsePromise = client.sandboxes.registries.update('name', {}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.sandboxes.registries.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sandboxes.registries.list( + { + limit: 0, + name_contains: 'name_contains', + offset: 0, + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.sandboxes.registries.delete('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/sandboxes/sandboxes.test.ts b/tests/api-resources/sandboxes/sandboxes.test.ts new file mode 100644 index 0000000..a2710f6 --- /dev/null +++ b/tests/api-resources/sandboxes/sandboxes.test.ts @@ -0,0 +1,39 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource sandboxes', () => { + // Mock server tests are disabled + test.skip('listUsageCosts: only required params', async () => { + const responsePromise = client.sandboxes.listUsageCosts({ + end_time: '2019-12-27T18:11:19.117Z', + start_time: '2019-12-27T18:11:19.117Z', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('listUsageCosts: required and optional params', async () => { + const response = await client.sandboxes.listUsageCosts({ + end_time: '2019-12-27T18:11:19.117Z', + start_time: '2019-12-27T18:11:19.117Z', + cursor: 'cursor', + granularity: 'HOUR', + page_size: 1, + resource_ids: ['string'], + resource_type: 'SANDBOX', + }); + }); +}); diff --git a/tests/api-resources/sandboxes/snapshots.test.ts b/tests/api-resources/sandboxes/snapshots.test.ts new file mode 100644 index 0000000..d5e1dba --- /dev/null +++ b/tests/api-resources/sandboxes/snapshots.test.ts @@ -0,0 +1,116 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource snapshots', () => { + // Mock server tests are disabled + test.skip('create: only required params', async () => { + const responsePromise = client.sandboxes.snapshots.create({ + docker_image: 'docker_image', + fs_capacity_bytes: 0, + name: 'name', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('create: required and optional params', async () => { + const response = await client.sandboxes.snapshots.create({ + docker_image: 'docker_image', + fs_capacity_bytes: 0, + name: 'name', + description: 'description', + labels: { foo: 'string' }, + registry_id: 'registry_id', + run_config: { + env_vars: { foo: 'string' }, + user: 'user', + work_dir: 'work_dir', + }, + tag: 'tag', + }); + }); + + // Mock server tests are disabled + test.skip('retrieve', async () => { + const responsePromise = client.sandboxes.snapshots.retrieve('snapshot_id'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list', async () => { + const responsePromise = client.sandboxes.snapshots.list(); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('list: request options and params are passed correctly', async () => { + // ensure the request options are being passed correctly by passing an invalid HTTP method in order to cause an error + await expect( + client.sandboxes.snapshots.list( + { + created_by: 'created_by', + cursor: 'cursor', + label: ['string'], + limit: 0, + name_contains: 'name_contains', + offset: 0, + page_size: 0, + sort_by: 'sort_by', + sort_direction: 'sort_direction', + sort_order: 'sort_order', + status: 'status', + }, + { path: '/_stainless_unknown_path' }, + ), + ).rejects.toThrow(Langsmith.NotFoundError); + }); + + // Mock server tests are disabled + test.skip('delete', async () => { + const responsePromise = client.sandboxes.snapshots.delete('snapshot_id'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieveByName', async () => { + const responsePromise = client.sandboxes.snapshots.retrieveByName('name'); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/api-resources/threads/share.test.ts b/tests/api-resources/threads/share.test.ts new file mode 100644 index 0000000..8391f65 --- /dev/null +++ b/tests/api-resources/threads/share.test.ts @@ -0,0 +1,74 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource share', () => { + // Mock server tests are disabled + test.skip('create: only required params', async () => { + const responsePromise = client.threads.share.create('thread_id', { + project_id: '018e4c7e-a9fb-7ef0-a5b6-6ea3a82e9327', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('create: required and optional params', async () => { + const response = await client.threads.share.create('thread_id', { + project_id: '018e4c7e-a9fb-7ef0-a5b6-6ea3a82e9327', + }); + }); + + // Mock server tests are disabled + test.skip('retrieve: only required params', async () => { + const responsePromise = client.threads.share.retrieve('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('retrieve: required and optional params', async () => { + const response = await client.threads.share.retrieve('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + }); + + // Mock server tests are disabled + test.skip('delete: only required params', async () => { + const responsePromise = client.threads.share.delete('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('delete: required and optional params', async () => { + const response = await client.threads.share.delete('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + }); +}); diff --git a/tests/api-resources/threads/threads.test.ts b/tests/api-resources/threads/threads.test.ts new file mode 100644 index 0000000..eef1301 --- /dev/null +++ b/tests/api-resources/threads/threads.test.ts @@ -0,0 +1,103 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource threads', () => { + // Mock server tests are disabled + test.skip('aggregateStats: only required params', async () => { + const responsePromise = client.threads.aggregateStats({ + project_id: '0190a1b2-c3d4-7ef0-a5b6-6ea3a82e9328', + select: ['THREAD_COUNT', 'TRACE_COUNT', 'TOTAL_TOKENS', 'TOTAL_COST'], + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('aggregateStats: required and optional params', async () => { + const response = await client.threads.aggregateStats({ + project_id: '0190a1b2-c3d4-7ef0-a5b6-6ea3a82e9328', + select: ['THREAD_COUNT', 'TRACE_COUNT', 'TOTAL_TOKENS', 'TOTAL_COST'], + filter: 'eq(status, "error")', + max_start_time: '2019-12-27T18:11:19.117Z', + min_start_time: '2019-12-27T18:11:19.117Z', + thread_filter: 'gte(turn_count, 3)', + trace_filter: 'eq(status, "error")', + tree_filter: 'has(tags, "production")', + }); + }); + + // Mock server tests are disabled + test.skip('listTraces: only required params', async () => { + const responsePromise = client.threads.listTraces('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('listTraces: required and optional params', async () => { + const response = await client.threads.listTraces('thread_id', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + cursor: 'cursor', + filter: 'filter', + page_size: 1, + selects: ['THREAD_ID'], + trace_filter: 'trace_filter', + tree_filter: 'tree_filter', + }); + }); + + // Mock server tests are disabled + test.skip('query', async () => { + const responsePromise = client.threads.query({}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('stats: only required params', async () => { + const responsePromise = client.threads.stats('thread_id', { + selects: ['TURNS'], + session_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('stats: required and optional params', async () => { + const response = await client.threads.stats('thread_id', { + selects: ['TURNS'], + session_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + filter: 'filter', + }); + }); +}); diff --git a/tests/api-resources/traces.test.ts b/tests/api-resources/traces.test.ts new file mode 100644 index 0000000..38931a4 --- /dev/null +++ b/tests/api-resources/traces.test.ts @@ -0,0 +1,49 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import Langsmith from 'langsmith'; + +const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', +}); + +describe('resource traces', () => { + // Mock server tests are disabled + test.skip('listRuns: only required params', async () => { + const responsePromise = client.traces.listRuns('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + }); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); + + // Mock server tests are disabled + test.skip('listRuns: required and optional params', async () => { + const response = await client.traces.listRuns('182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', { + project_id: '182bd5e5-6e1a-4fe4-a799-aa6d9a6ab26e', + filter: 'filter', + max_start_time: '2019-12-27T18:11:19.117Z', + min_start_time: '2019-12-27T18:11:19.117Z', + selects: ['ID'], + Accept: 'Accept', + }); + }); + + // Mock server tests are disabled + test.skip('query', async () => { + const responsePromise = client.traces.query({}); + const rawResponse = await responsePromise.asResponse(); + expect(rawResponse).toBeInstanceOf(Response); + const response = await responsePromise; + expect(response).not.toBeInstanceOf(Response); + const dataAndResponse = await responsePromise.withResponse(); + expect(dataAndResponse.data).toBe(response); + expect(dataAndResponse.response).toBe(rawResponse); + }); +}); diff --git a/tests/base64.test.ts b/tests/base64.test.ts new file mode 100644 index 0000000..0224d7d --- /dev/null +++ b/tests/base64.test.ts @@ -0,0 +1,80 @@ +import { fromBase64, toBase64 } from 'langsmith/internal/utils/base64'; + +describe.each(['Buffer', 'atob'])('with %s', (mode) => { + let originalBuffer: BufferConstructor; + beforeAll(() => { + if (mode === 'atob') { + originalBuffer = globalThis.Buffer; + // @ts-expect-error Can't assign undefined to BufferConstructor + delete globalThis.Buffer; + } + }); + afterAll(() => { + if (mode === 'atob') { + globalThis.Buffer = originalBuffer; + } + }); + test('toBase64', () => { + const testCases = [ + { + input: 'hello world', + expected: 'aGVsbG8gd29ybGQ=', + }, + { + input: new Uint8Array([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]), + expected: 'aGVsbG8gd29ybGQ=', + }, + { + input: undefined, + expected: '', + }, + { + input: new Uint8Array([ + 229, 102, 215, 230, 65, 22, 46, 87, 243, 176, 99, 99, 31, 174, 8, 242, 83, 142, 169, 64, 122, 123, + 193, 71, + ]), + expected: '5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH', + }, + { + input: '✓', + expected: '4pyT', + }, + { + input: new Uint8Array([226, 156, 147]), + expected: '4pyT', + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(toBase64(input)).toBe(expected); + }); + }); + + test('fromBase64', () => { + const testCases = [ + { + input: 'aGVsbG8gd29ybGQ=', + expected: new Uint8Array([104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100]), + }, + { + input: '', + expected: new Uint8Array([]), + }, + { + input: '5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH', + expected: new Uint8Array([ + 229, 102, 215, 230, 65, 22, 46, 87, 243, 176, 99, 99, 31, 174, 8, 242, 83, 142, 169, 64, 122, 123, + 193, 71, + ]), + }, + { + input: '4pyT', + expected: new Uint8Array([226, 156, 147]), + }, + ]; + + testCases.forEach(({ input, expected }) => { + expect(fromBase64(input)).toEqual(expected); + }); + }); +}); diff --git a/tests/buildHeaders.test.ts b/tests/buildHeaders.test.ts new file mode 100644 index 0000000..0c925d2 --- /dev/null +++ b/tests/buildHeaders.test.ts @@ -0,0 +1,88 @@ +import { inspect } from 'node:util'; +import { buildHeaders, type HeadersLike, type NullableHeaders } from 'langsmith/internal/headers'; + +function inspectNullableHeaders(headers: NullableHeaders) { + return `NullableHeaders {${[ + ...[...headers.values.entries()].map(([name, value]) => ` ${inspect(name)}: ${inspect(value)}`), + ...[...headers.nulls].map((name) => ` ${inspect(name)}: null`), + ].join(', ')} }`; +} + +describe('buildHeaders', () => { + const cases: [HeadersLike[], string][] = [ + [[new Headers({ 'content-type': 'text/plain' })], `NullableHeaders { 'content-type': 'text/plain' }`], + [ + [ + { + 'content-type': 'text/plain', + }, + { + 'Content-Type': undefined, + }, + ], + `NullableHeaders { 'content-type': 'text/plain' }`, + ], + [ + [ + { + 'content-type': 'text/plain', + }, + { + 'Content-Type': null, + }, + ], + `NullableHeaders { 'content-type': null }`, + ], + [ + [ + { + cookie: 'name1=value1', + Cookie: 'name2=value2', + }, + ], + `NullableHeaders { 'cookie': 'name2=value2' }`, + ], + [ + [ + { + cookie: 'name1=value1', + Cookie: undefined, + }, + ], + `NullableHeaders { 'cookie': 'name1=value1' }`, + ], + [ + [ + { + cookie: ['name1=value1', 'name2=value2'], + }, + ], + `NullableHeaders { 'cookie': 'name1=value1; name2=value2' }`, + ], + [ + [ + { + 'x-foo': ['name1=value1', 'name2=value2'], + }, + ], + `NullableHeaders { 'x-foo': 'name1=value1, name2=value2' }`, + ], + [ + [ + [ + ['cookie', 'name1=value1'], + ['cookie', 'name2=value2'], + ['Cookie', 'name3=value3'], + ], + ], + `NullableHeaders { 'cookie': 'name1=value1; name2=value2; name3=value3' }`, + ], + [[undefined], `NullableHeaders { }`], + [[null], `NullableHeaders { }`], + ]; + for (const [input, expected] of cases) { + test(expected, () => { + expect(inspectNullableHeaders(buildHeaders(input))).toEqual(expected); + }); + } +}); diff --git a/tests/form.test.ts b/tests/form.test.ts new file mode 100644 index 0000000..8e9412d --- /dev/null +++ b/tests/form.test.ts @@ -0,0 +1,85 @@ +import { multipartFormRequestOptions, createForm } from 'langsmith/internal/uploads'; +import { toFile } from 'langsmith/core/uploads'; + +describe('form data validation', () => { + test('valid values do not error', async () => { + await multipartFormRequestOptions( + { + body: { + foo: 'foo', + string: 1, + bool: true, + file: await toFile(Buffer.from('some-content')), + blob: new Blob(['Some content'], { type: 'text/plain' }), + }, + }, + fetch, + ); + }); + + test('null', async () => { + await expect(() => + multipartFormRequestOptions( + { + body: { + null: null, + }, + }, + fetch, + ), + ).rejects.toThrow(TypeError); + }); + + test('undefined is stripped', async () => { + const form = await createForm( + { + foo: undefined, + bar: 'baz', + }, + fetch, + ); + expect(form.has('foo')).toBe(false); + expect(form.get('bar')).toBe('baz'); + }); + + test('nested undefined property is stripped', async () => { + const form = await createForm( + { + bar: { + baz: undefined, + }, + }, + fetch, + ); + expect(Array.from(form.entries())).toEqual([]); + + const form2 = await createForm( + { + bar: { + foo: 'string', + baz: undefined, + }, + }, + fetch, + ); + expect(Array.from(form2.entries())).toEqual([['bar[foo]', 'string']]); + }); + + test('nested undefined array item is stripped', async () => { + const form = await createForm( + { + bar: [undefined, undefined], + }, + fetch, + ); + expect(Array.from(form.entries())).toEqual([]); + + const form2 = await createForm( + { + bar: [undefined, 'foo'], + }, + fetch, + ); + expect(Array.from(form2.entries())).toEqual([['bar[]', 'foo']]); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..1c2ae63 --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,826 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { APIPromise } from 'langsmith/core/api-promise'; + +import util from 'node:util'; +import Langsmith from 'langsmith'; +import { APIUserAbortError } from 'langsmith'; +const defaultFetch = fetch; + +describe('instantiate client', () => { + const env = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...env }; + }); + + afterEach(() => { + process.env = env; + }); + + describe('defaultHeaders', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + defaultHeaders: { 'X-My-Default-Header': '2' }, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + test('they are used in the request', async () => { + const { req } = await client.buildRequest({ path: '/foo', method: 'post' }); + expect(req.headers.get('x-my-default-header')).toEqual('2'); + }); + + test('can ignore `undefined` and leave the default', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + headers: { 'X-My-Default-Header': undefined }, + }); + expect(req.headers.get('x-my-default-header')).toEqual('2'); + }); + + test('can be removed with `null`', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + headers: { 'X-My-Default-Header': null }, + }); + expect(req.headers.has('x-my-default-header')).toBe(false); + }); + }); + describe('logging', () => { + const env = process.env; + + beforeEach(() => { + process.env = { ...env }; + process.env['LANGCHAIN_LOG'] = undefined; + }); + + afterEach(() => { + process.env = env; + }); + + const forceAPIResponseForClient = async (client: Langsmith) => { + await new APIPromise( + client, + Promise.resolve({ + response: new Response(), + controller: new AbortController(), + requestLogID: 'log_000000', + retryOfRequestLogID: undefined, + startTime: Date.now(), + options: { + method: 'get', + path: '/', + }, + }), + ); + }; + + test('debug logs when log level is debug', async () => { + const debugMock = jest.fn(); + const logger = { + debug: debugMock, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new Langsmith({ + logger: logger, + logLevel: 'debug', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + await forceAPIResponseForClient(client); + expect(debugMock).toHaveBeenCalled(); + }); + + test('default logLevel is warn', async () => { + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.logLevel).toBe('warn'); + }); + + test('debug logs are skipped when log level is info', async () => { + const debugMock = jest.fn(); + const logger = { + debug: debugMock, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + const client = new Langsmith({ + logger: logger, + logLevel: 'info', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + await forceAPIResponseForClient(client); + expect(debugMock).not.toHaveBeenCalled(); + }); + + test('debug logs happen with debug env var', async () => { + const debugMock = jest.fn(); + const logger = { + debug: debugMock, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + process.env['LANGCHAIN_LOG'] = 'debug'; + const client = new Langsmith({ + logger: logger, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.logLevel).toBe('debug'); + + await forceAPIResponseForClient(client); + expect(debugMock).toHaveBeenCalled(); + }); + + test('warn when env var level is invalid', async () => { + const warnMock = jest.fn(); + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: warnMock, + error: jest.fn(), + }; + + process.env['LANGCHAIN_LOG'] = 'not a log level'; + const client = new Langsmith({ + logger: logger, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.logLevel).toBe('warn'); + expect(warnMock).toHaveBeenCalledWith( + 'process.env[\'LANGCHAIN_LOG\'] was set to "not a log level", expected one of ["off","error","warn","info","debug"]', + ); + }); + + test('client log level overrides env var', async () => { + const debugMock = jest.fn(); + const logger = { + debug: debugMock, + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + process.env['LANGCHAIN_LOG'] = 'debug'; + const client = new Langsmith({ + logger: logger, + logLevel: 'off', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + await forceAPIResponseForClient(client); + expect(debugMock).not.toHaveBeenCalled(); + }); + + test('no warning logged for invalid env var level + valid client level', async () => { + const warnMock = jest.fn(); + const logger = { + debug: jest.fn(), + info: jest.fn(), + warn: warnMock, + error: jest.fn(), + }; + + process.env['LANGCHAIN_LOG'] = 'not a log level'; + const client = new Langsmith({ + logger: logger, + logLevel: 'debug', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.logLevel).toBe('debug'); + expect(warnMock).not.toHaveBeenCalled(); + }); + }); + + describe('defaultQuery', () => { + test('with null query params given', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + defaultQuery: { apiVersion: 'foo' }, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.buildURL('/foo', null)).toEqual('http://localhost:5000/foo?apiVersion=foo'); + }); + + test('multiple default query params', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + defaultQuery: { apiVersion: 'foo', hello: 'world' }, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.buildURL('/foo', null)).toEqual('http://localhost:5000/foo?apiVersion=foo&hello=world'); + }); + + test('overriding with `undefined`', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + defaultQuery: { hello: 'world' }, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.buildURL('/foo', { hello: undefined })).toEqual('http://localhost:5000/foo'); + }); + }); + + test('custom fetch', async () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: (url) => { + return Promise.resolve( + new Response(JSON.stringify({ url, custom: true }), { + headers: { 'Content-Type': 'application/json' }, + }), + ); + }, + }); + + const response = await client.get('/foo'); + expect(response).toEqual({ url: 'http://localhost:5000/foo', custom: true }); + }); + + test('explicit global fetch', async () => { + // make sure the global fetch type is assignable to our Fetch type + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: defaultFetch, + }); + }); + + test('custom signal', async () => { + const client = new Langsmith({ + baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: (...args) => { + return new Promise((resolve, reject) => + setTimeout( + () => + defaultFetch(...args) + .then(resolve) + .catch(reject), + 300, + ), + ); + }, + }); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 200); + + const spy = jest.spyOn(client, 'request'); + + await expect(client.get('/foo', { signal: controller.signal })).rejects.toThrowError(APIUserAbortError); + expect(spy).toHaveBeenCalledTimes(1); + }); + + test('normalized method', async () => { + let capturedRequest: RequestInit | undefined; + const testFetch = async (url: string | URL | Request, init: RequestInit = {}): Promise => { + capturedRequest = init; + return new Response(JSON.stringify({}), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + }); + + await client.patch('/foo'); + expect(capturedRequest?.method).toEqual('PATCH'); + }); + + describe('baseUrl', () => { + test('trailing slash', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/custom/path/', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.buildURL('/foo', null)).toEqual('http://localhost:5000/custom/path/foo'); + }); + + test('no trailing slash', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/custom/path', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.buildURL('/foo', null)).toEqual('http://localhost:5000/custom/path/foo'); + }); + + afterEach(() => { + process.env['LANGCHAIN_BASE_URL'] = undefined; + }); + + test('explicit option', () => { + const client = new Langsmith({ + baseURL: 'https://example.com', + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.baseURL).toEqual('https://example.com'); + }); + + test('env variable', () => { + process.env['LANGCHAIN_BASE_URL'] = 'https://example.com/from_env'; + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.baseURL).toEqual('https://example.com/from_env'); + }); + + test('empty env variable', () => { + process.env['LANGCHAIN_BASE_URL'] = ''; // empty + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.baseURL).toEqual('https://api.smith.langchain.com/'); + }); + + test('blank env variable', () => { + process.env['LANGCHAIN_BASE_URL'] = ' '; // blank + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.baseURL).toEqual('https://api.smith.langchain.com/'); + }); + + test('in request options', () => { + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/option/foo', + ); + }); + + test('in request options overridden by client options', () => { + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + baseURL: 'http://localhost:5000/client', + }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/client/foo', + ); + }); + + test('in request options overridden by env variable', () => { + process.env['LANGCHAIN_BASE_URL'] = 'http://localhost:5000/env'; + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.buildURL('/foo', null, 'http://localhost:5000/option')).toEqual( + 'http://localhost:5000/env/foo', + ); + }); + }); + + test('maxRetries option is correctly set', () => { + const client = new Langsmith({ + maxRetries: 4, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + expect(client.maxRetries).toEqual(4); + + // default + const client2 = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client2.maxRetries).toEqual(2); + }); + + describe('withOptions', () => { + test('creates a new client with overridden options', async () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + maxRetries: 3, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + const newClient = client.withOptions({ + maxRetries: 5, + baseURL: 'http://localhost:5001/', + }); + + // Verify the new client has updated options + expect(newClient.maxRetries).toEqual(5); + expect(newClient.baseURL).toEqual('http://localhost:5001/'); + + // Verify the original client is unchanged + expect(client.maxRetries).toEqual(3); + expect(client.baseURL).toEqual('http://localhost:5000/'); + + // Verify it's a different instance + expect(newClient).not.toBe(client); + expect(newClient.constructor).toBe(client.constructor); + }); + + test('inherits options from the parent client', async () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + defaultHeaders: { 'X-Test-Header': 'test-value' }, + defaultQuery: { 'test-param': 'test-value' }, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + const newClient = client.withOptions({ + baseURL: 'http://localhost:5001/', + }); + + // Test inherited options remain the same + expect(newClient.buildURL('/foo', null)).toEqual('http://localhost:5001/foo?test-param=test-value'); + + const { req } = await newClient.buildRequest({ path: '/foo', method: 'get' }); + expect(req.headers.get('x-test-header')).toEqual('test-value'); + }); + + test('respects runtime property changes when creating new client', () => { + const client = new Langsmith({ + baseURL: 'http://localhost:5000/', + timeout: 1000, + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + }); + + // Modify the client properties directly after creation + client.baseURL = 'http://localhost:6000/'; + client.timeout = 2000; + + // Create a new client with withOptions + const newClient = client.withOptions({ + maxRetries: 10, + }); + + // Verify the new client uses the updated properties, not the original ones + expect(newClient.baseURL).toEqual('http://localhost:6000/'); + expect(newClient.timeout).toEqual(2000); + expect(newClient.maxRetries).toEqual(10); + + // Original client should still have its modified properties + expect(client.baseURL).toEqual('http://localhost:6000/'); + expect(client.timeout).toEqual(2000); + expect(client.maxRetries).not.toEqual(10); + + // Verify URL building uses the updated baseURL + expect(newClient.buildURL('/bar', null)).toEqual('http://localhost:6000/bar'); + }); + }); + + test('with environment variable arguments', () => { + // set options via env var + process.env['LANGSMITH_API_KEY'] = 'My API Key'; + process.env['LANGSMITH_TENANT_ID'] = 'My Tenant ID'; + const client = new Langsmith(); + expect(client.apiKey).toBe('My API Key'); + expect(client.tenantID).toBe('My Tenant ID'); + }); + + test('with overridden environment variable arguments', () => { + // set options via env var + process.env['LANGSMITH_API_KEY'] = 'another My API Key'; + process.env['LANGSMITH_TENANT_ID'] = 'another My Tenant ID'; + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + expect(client.apiKey).toBe('My API Key'); + expect(client.tenantID).toBe('My Tenant ID'); + }); +}); + +describe('request building', () => { + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + + describe('custom headers', () => { + test('handles undefined', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: { value: 'hello' }, + headers: { 'X-Foo': 'baz', 'x-foo': 'bar', 'x-Foo': undefined, 'x-baz': 'bam', 'X-Baz': null }, + }); + expect(req.headers.get('x-foo')).toEqual('bar'); + expect(req.headers.get('x-Foo')).toEqual('bar'); + expect(req.headers.get('X-Foo')).toEqual('bar'); + expect(req.headers.get('x-baz')).toEqual(null); + }); + }); +}); + +describe('default encoder', () => { + const client = new Langsmith({ apiKey: 'My API Key', tenantID: 'My Tenant ID' }); + + class Serializable { + toJSON() { + return { $type: 'Serializable' }; + } + } + class Collection { + #things: T[]; + constructor(things: T[]) { + this.#things = Array.from(things); + } + toJSON() { + return Array.from(this.#things); + } + [Symbol.iterator]() { + return this.#things[Symbol.iterator]; + } + } + for (const jsonValue of [{}, [], { __proto__: null }, new Serializable(), new Collection(['item'])]) { + test(`serializes ${util.inspect(jsonValue)} as json`, async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: jsonValue, + }); + expect(req.headers).toBeInstanceOf(Headers); + expect(req.headers.get('content-type')).toEqual('application/json'); + expect(req.body).toBe(JSON.stringify(jsonValue)); + }); + } + + const encoder = new TextEncoder(); + const asyncIterable = (async function* () { + yield encoder.encode('a\n'); + yield encoder.encode('b\n'); + yield encoder.encode('c\n'); + })(); + for (const streamValue of [ + [encoder.encode('a\nb\nc\n')][Symbol.iterator](), + new Response('a\nb\nc\n').body, + asyncIterable, + ]) { + test(`converts ${util.inspect(streamValue)} to ReadableStream`, async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: streamValue, + }); + expect(req.headers).toBeInstanceOf(Headers); + expect(req.headers.get('content-type')).toEqual(null); + expect(req.body).toBeInstanceOf(ReadableStream); + expect(await new Response(req.body).text()).toBe('a\nb\nc\n'); + }); + } + + test(`can set content-type for ReadableStream`, async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: new Response('a\nb\nc\n').body, + headers: { 'Content-Type': 'text/plain' }, + }); + expect(req.headers).toBeInstanceOf(Headers); + expect(req.headers.get('content-type')).toEqual('text/plain'); + expect(req.body).toBeInstanceOf(ReadableStream); + expect(await new Response(req.body).text()).toBe('a\nb\nc\n'); + }); +}); + +describe('retries', () => { + test('retry on timeout', async () => { + let count = 0; + const testFetch = async ( + url: string | URL | Request, + { signal }: RequestInit = {}, + ): Promise => { + if (count++ === 0) { + return new Promise( + (resolve, reject) => signal?.addEventListener('abort', () => reject(new Error('timed out'))), + ); + } + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + timeout: 10, + fetch: testFetch, + }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + expect(count).toEqual(2); + expect( + await client + .request({ path: '/foo', method: 'get' }) + .asResponse() + .then((r) => r.text()), + ).toEqual(JSON.stringify({ a: 1 })); + expect(count).toEqual(3); + }); + + test('retry count header', async () => { + let count = 0; + let capturedRequest: RequestInit | undefined; + const testFetch = async (url: string | URL | Request, init: RequestInit = {}): Promise => { + count++; + if (count <= 2) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + capturedRequest = init; + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + maxRetries: 4, + }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + + expect((capturedRequest!.headers as Headers).get('x-stainless-retry-count')).toEqual('2'); + expect(count).toEqual(3); + }); + + test('omit retry count header', async () => { + let count = 0; + let capturedRequest: RequestInit | undefined; + const testFetch = async (url: string | URL | Request, init: RequestInit = {}): Promise => { + count++; + if (count <= 2) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + capturedRequest = init; + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + maxRetries: 4, + }); + + expect( + await client.request({ + path: '/foo', + method: 'get', + headers: { 'X-Stainless-Retry-Count': null }, + }), + ).toEqual({ a: 1 }); + + expect((capturedRequest!.headers as Headers).has('x-stainless-retry-count')).toBe(false); + }); + + test('omit retry count header by default', async () => { + let count = 0; + let capturedRequest: RequestInit | undefined; + const testFetch = async (url: string | URL | Request, init: RequestInit = {}): Promise => { + count++; + if (count <= 2) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + capturedRequest = init; + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + maxRetries: 4, + defaultHeaders: { 'X-Stainless-Retry-Count': null }, + }); + + expect( + await client.request({ + path: '/foo', + method: 'get', + }), + ).toEqual({ a: 1 }); + + expect(capturedRequest!.headers as Headers).not.toHaveProperty('x-stainless-retry-count'); + }); + + test('overwrite retry count header', async () => { + let count = 0; + let capturedRequest: RequestInit | undefined; + const testFetch = async (url: string | URL | Request, init: RequestInit = {}): Promise => { + count++; + if (count <= 2) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + capturedRequest = init; + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + maxRetries: 4, + }); + + expect( + await client.request({ + path: '/foo', + method: 'get', + headers: { 'X-Stainless-Retry-Count': '42' }, + }), + ).toEqual({ a: 1 }); + + expect((capturedRequest!.headers as Headers).get('x-stainless-retry-count')).toEqual('42'); + }); + + test('retry on 429 with retry-after', async () => { + let count = 0; + const testFetch = async ( + url: string | URL | Request, + { signal }: RequestInit = {}, + ): Promise => { + if (count++ === 0) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After': '0.1', + }, + }); + } + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + expect(count).toEqual(2); + expect( + await client + .request({ path: '/foo', method: 'get' }) + .asResponse() + .then((r) => r.text()), + ).toEqual(JSON.stringify({ a: 1 })); + expect(count).toEqual(3); + }); + + test('retry on 429 with retry-after-ms', async () => { + let count = 0; + const testFetch = async ( + url: string | URL | Request, + { signal }: RequestInit = {}, + ): Promise => { + if (count++ === 0) { + return new Response(undefined, { + status: 429, + headers: { + 'Retry-After-Ms': '10', + }, + }); + } + return new Response(JSON.stringify({ a: 1 }), { headers: { 'Content-Type': 'application/json' } }); + }; + + const client = new Langsmith({ + apiKey: 'My API Key', + tenantID: 'My Tenant ID', + fetch: testFetch, + }); + + expect(await client.request({ path: '/foo', method: 'get' })).toEqual({ a: 1 }); + expect(count).toEqual(2); + expect( + await client + .request({ path: '/foo', method: 'get' }) + .asResponse() + .then((r) => r.text()), + ).toEqual(JSON.stringify({ a: 1 })); + expect(count).toEqual(3); + }); +}); diff --git a/tests/path.test.ts b/tests/path.test.ts new file mode 100644 index 0000000..31e8083 --- /dev/null +++ b/tests/path.test.ts @@ -0,0 +1,462 @@ +import { createPathTagFunction, encodeURIPath } from 'langsmith/internal/utils/path'; +import { inspect } from 'node:util'; +import { runInNewContext } from 'node:vm'; + +describe('path template tag function', () => { + test('validates input', () => { + const testParams = ['', '.', '..', 'x', '%2e', '%2E', '%2e%2e', '%2E%2e', '%2e%2E', '%2E%2E']; + const testCases = [ + ['/path_params/', '/a'], + ['/path_params/', '/'], + ['/path_params/', ''], + ['', '/a'], + ['', '/'], + ['', ''], + ['a'], + [''], + ['/path_params/', ':initiate'], + ['/path_params/', '.json'], + ['/path_params/', '?beta=true'], + ['/path_params/', '.?beta=true'], + ['/path_params/', '/', '/download'], + ['/path_params/', '-', '/download'], + ['/path_params/', '', '/download'], + ['/path_params/', '.', '/download'], + ['/path_params/', '..', '/download'], + ['/plain/path'], + ]; + + function paramPermutations(len: number): string[][] { + if (len === 0) return []; + if (len === 1) return testParams.map((e) => [e]); + const rest = paramPermutations(len - 1); + return testParams.flatMap((e) => rest.map((r) => [e, ...r])); + } + + // We need to test how %2E is handled, so we use a custom encoder that does no escaping. + const rawPath = createPathTagFunction((s) => s); + + const emptyObject = {}; + const mathObject = Math; + const numberObject = new Number(); + const stringObject = new String(); + const basicClass = new (class {})(); + const classWithToString = new (class { + toString() { + return 'ok'; + } + })(); + + // Invalid values + expect(() => rawPath`/a/${null}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Null is not a valid path parameter\n' + + '/a/null/b\n' + + ' ^^^^', + ); + expect(() => rawPath`/a/${undefined}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Undefined is not a valid path parameter\n' + + '/a/undefined/b\n' + + ' ^^^^^^^^^', + ); + expect(() => rawPath`/a/${emptyObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${mathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${basicClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^', + ); + expect(() => rawPath`/../${''}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '/../\n' + + ' ^^', + ); + expect(() => rawPath`/../${{}}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + 'Value of type Object is not a valid path parameter\n' + + '/../[object Object]\n' + + ' ^^ ^^^^^^^^^^^^^^', + ); + + // Valid values + expect(rawPath`/${0}`).toBe('/0'); + expect(rawPath`/${''}`).toBe('/'); + expect(rawPath`/${numberObject}`).toBe('/0'); + expect(rawPath`${stringObject}/`).toBe('/'); + expect(rawPath`/${classWithToString}`).toBe('/ok'); + + // We need to check what happens with cross-realm values, which we might get from + // Jest or other frames in a browser. + + const newRealm = runInNewContext('globalThis'); + expect(newRealm.Object).not.toBe(Object); + + const crossRealmObject = newRealm.Object(); + const crossRealmMathObject = newRealm.Math; + const crossRealmNumber = new newRealm.Number(); + const crossRealmString = new newRealm.String(); + const crossRealmClass = new (class extends newRealm.Object {})(); + const crossRealmClassWithToString = new (class extends newRealm.Object { + toString() { + return 'ok'; + } + })(); + + // Invalid cross-realm values + expect(() => rawPath`/a/${crossRealmObject}/b`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/a/[object Object]/b\n' + + ' ^^^^^^^^^^^^^^^', + ); + expect(() => rawPath`?${crossRealmMathObject}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Math is not a valid path parameter\n' + + '?[object Math]\n' + + ' ^^^^^^^^^^^^^', + ); + expect(() => rawPath`/${crossRealmClass}`).toThrow( + 'Path parameters result in path with invalid segments:\n' + + 'Value of type Object is not a valid path parameter\n' + + '/[object Object]\n' + + ' ^^^^^^^^^^^^^^^', + ); + + // Valid cross-realm values + expect(rawPath`/${crossRealmNumber}`).toBe('/0'); + expect(rawPath`${crossRealmString}/`).toBe('/'); + expect(rawPath`/${crossRealmClassWithToString}`).toBe('/ok'); + + const results: { + [pathParts: string]: { + [params: string]: { valid: boolean; result?: string; error?: string }; + }; + } = {}; + + for (const pathParts of testCases) { + const pathResults: Record = {}; + results[JSON.stringify(pathParts)] = pathResults; + for (const params of paramPermutations(pathParts.length - 1)) { + const stringRaw = String.raw({ raw: pathParts }, ...params); + const plainString = String.raw( + { raw: pathParts.map((e) => e.replace(/\./g, 'x')) }, + ...params.map((e) => 'X'.repeat(e.length)), + ); + const normalizedStringRaw = new URL(stringRaw, 'https://example.com').href; + const normalizedPlainString = new URL(plainString, 'https://example.com').href; + const pathResultsKey = JSON.stringify(params); + try { + const result = rawPath(pathParts, ...params); + expect(result).toBe(stringRaw); + // there are no special segments, so the length of the normalized path is + // equal to the length of the normalized plain path. + expect(normalizedStringRaw.length).toBe(normalizedPlainString.length); + pathResults[pathResultsKey] = { + valid: true, + result, + }; + } catch (e) { + const error = String(e); + expect(error).toMatch(/Path parameters result in path with invalid segment/); + // there are special segments, so the length of the normalized path is + // different than the length of the normalized plain path. + expect(normalizedStringRaw.length).not.toBe(normalizedPlainString.length); + pathResults[pathResultsKey] = { + valid: false, + error, + }; + } + } + } + + expect(results).toMatchObject({ + '["/path_params/","/a"]': { + '["x"]': { valid: true, result: '/path_params/x/a' }, + '[""]': { valid: true, result: '/path_params//a' }, + '["%2E%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E%2e/a\n' + + ' ^^^^^^', + }, + '["%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E/a\n' + + ' ^^^', + }, + }, + '["/path_params/","/"]': { + '["x"]': { valid: true, result: '/path_params/x/' }, + '[""]': { valid: true, result: '/path_params//' }, + '["%2e%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2e%2E/\n' + + ' ^^^^^^', + }, + '["%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + + '/path_params/%2e/\n' + + ' ^^^', + }, + }, + '["/path_params/",""]': { + '[""]': { valid: true, result: '/path_params/' }, + '["x"]': { valid: true, result: '/path_params/x' }, + '["%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E\n' + + ' ^^^', + }, + '["%2E%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E%2e\n' + + ' ^^^^^^', + }, + }, + '["","/a"]': { + '[""]': { valid: true, result: '/a' }, + '["x"]': { valid: true, result: 'x/a' }, + '["%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n%2E/a\n^^^', + }, + '["%2e%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + + '%2e%2E/a\n' + + '^^^^^^', + }, + }, + '["","/"]': { + '["x"]': { valid: true, result: 'x/' }, + '[""]': { valid: true, result: '/' }, + '["%2E%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + '%2E%2e/\n' + + '^^^^^^', + }, + '["."]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + './\n^', + }, + }, + '["",""]': { + '[""]': { valid: true, result: '' }, + '["x"]': { valid: true, result: 'x' }, + '[".."]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '..\n^^', + }, + '["."]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + '.\n^', + }, + }, + '["a"]': {}, + '[""]': {}, + '["/path_params/",":initiate"]': { + '[""]': { valid: true, result: '/path_params/:initiate' }, + '["."]': { valid: true, result: '/path_params/.:initiate' }, + }, + '["/path_params/",".json"]': { + '["x"]': { valid: true, result: '/path_params/x.json' }, + '["."]': { valid: true, result: '/path_params/..json' }, + }, + '["/path_params/","?beta=true"]': { + '["x"]': { valid: true, result: '/path_params/x?beta=true' }, + '[""]': { valid: true, result: '/path_params/?beta=true' }, + '["%2E%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E%2E?beta=true\n' + + ' ^^^^^^', + }, + '["%2e%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2e%2E?beta=true\n' + + ' ^^^^^^', + }, + }, + '["/path_params/",".?beta=true"]': { + '[".."]': { valid: true, result: '/path_params/...?beta=true' }, + '["x"]': { valid: true, result: '/path_params/x.?beta=true' }, + '[""]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + '/path_params/.?beta=true\n' + + ' ^', + }, + '["%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2e." can\'t be safely passed as a path parameter\n' + + '/path_params/%2e.?beta=true\n' + + ' ^^^^', + }, + }, + '["/path_params/","/","/download"]': { + '["",""]': { valid: true, result: '/path_params///download' }, + '["","x"]': { valid: true, result: '/path_params//x/download' }, + '[".","%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + + '/path_params/./%2e/download\n' + + ' ^ ^^^', + }, + '["%2E%2e","%2e"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E%2e" can\'t be safely passed as a path parameter\n' + + 'Value "%2e" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E%2e/%2e/download\n' + + ' ^^^^^^ ^^^', + }, + }, + '["/path_params/","-","/download"]': { + '["","%2e"]': { valid: true, result: '/path_params/-%2e/download' }, + '["%2E",".."]': { valid: true, result: '/path_params/%2E-../download' }, + }, + '["/path_params/","","/download"]': { + '["%2E%2e","%2e%2E"]': { valid: true, result: '/path_params/%2E%2e%2e%2E/download' }, + '["%2E",".."]': { valid: true, result: '/path_params/%2E../download' }, + '["","%2E"]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E" can\'t be safely passed as a path parameter\n' + + '/path_params/%2E/download\n' + + ' ^^^', + }, + '["%2E","."]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "%2E." can\'t be safely passed as a path parameter\n' + + '/path_params/%2E./download\n' + + ' ^^^^', + }, + }, + '["/path_params/",".","/download"]': { + '["%2e%2e",""]': { valid: true, result: '/path_params/%2e%2e./download' }, + '["","%2e%2e"]': { valid: true, result: '/path_params/.%2e%2e/download' }, + '["",""]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value "." can\'t be safely passed as a path parameter\n' + + '/path_params/./download\n' + + ' ^', + }, + '["","."]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '/path_params/../download\n' + + ' ^^', + }, + }, + '["/path_params/","..","/download"]': { + '["","%2E"]': { valid: true, result: '/path_params/..%2E/download' }, + '["","x"]': { valid: true, result: '/path_params/..x/download' }, + '["",""]': { + valid: false, + error: + 'Error: Path parameters result in path with invalid segments:\n' + + 'Value ".." can\'t be safely passed as a path parameter\n' + + '/path_params/../download\n' + + ' ^^', + }, + }, + }); + }); +}); + +describe('encodeURIPath', () => { + const testCases: string[] = [ + '', + // Every ASCII character + ...Array.from({ length: 0x7f }, (_, i) => String.fromCharCode(i)), + // Unicode BMP codepoint + 'å', + // Unicode supplementary codepoint + '😃', + ]; + + for (const param of testCases) { + test('properly encodes ' + inspect(param), () => { + const encoded = encodeURIPath(param); + const naiveEncoded = encodeURIComponent(param); + // we should never encode more characters than encodeURIComponent + expect(naiveEncoded.length).toBeGreaterThanOrEqual(encoded.length); + expect(decodeURIComponent(encoded)).toBe(param); + }); + } + + test("leaves ':' intact", () => { + expect(encodeURIPath(':')).toBe(':'); + }); + + test("leaves '@' intact", () => { + expect(encodeURIPath('@')).toBe('@'); + }); +}); diff --git a/tests/qs/empty-keys-cases.ts b/tests/qs/empty-keys-cases.ts new file mode 100644 index 0000000..ea2c1b0 --- /dev/null +++ b/tests/qs/empty-keys-cases.ts @@ -0,0 +1,271 @@ +export const empty_test_cases = [ + { + input: '&', + with_empty_keys: {}, + stringify_output: { + brackets: '', + indices: '', + repeat: '', + }, + no_empty_keys: {}, + }, + { + input: '&&', + with_empty_keys: {}, + stringify_output: { + brackets: '', + indices: '', + repeat: '', + }, + no_empty_keys: {}, + }, + { + input: '&=', + with_empty_keys: { '': '' }, + stringify_output: { + brackets: '=', + indices: '=', + repeat: '=', + }, + no_empty_keys: {}, + }, + { + input: '&=&', + with_empty_keys: { '': '' }, + stringify_output: { + brackets: '=', + indices: '=', + repeat: '=', + }, + no_empty_keys: {}, + }, + { + input: '&=&=', + with_empty_keys: { '': ['', ''] }, + stringify_output: { + brackets: '[]=&[]=', + indices: '[0]=&[1]=', + repeat: '=&=', + }, + no_empty_keys: {}, + }, + { + input: '&=&=&', + with_empty_keys: { '': ['', ''] }, + stringify_output: { + brackets: '[]=&[]=', + indices: '[0]=&[1]=', + repeat: '=&=', + }, + no_empty_keys: {}, + }, + { + input: '=', + with_empty_keys: { '': '' }, + no_empty_keys: {}, + stringify_output: { + brackets: '=', + indices: '=', + repeat: '=', + }, + }, + { + input: '=&', + with_empty_keys: { '': '' }, + stringify_output: { + brackets: '=', + indices: '=', + repeat: '=', + }, + no_empty_keys: {}, + }, + { + input: '=&&&', + with_empty_keys: { '': '' }, + stringify_output: { + brackets: '=', + indices: '=', + repeat: '=', + }, + no_empty_keys: {}, + }, + { + input: '=&=&=&', + with_empty_keys: { '': ['', '', ''] }, + stringify_output: { + brackets: '[]=&[]=&[]=', + indices: '[0]=&[1]=&[2]=', + repeat: '=&=&=', + }, + no_empty_keys: {}, + }, + { + input: '=&a[]=b&a[1]=c', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: '=a', + with_empty_keys: { '': 'a' }, + no_empty_keys: {}, + stringify_output: { + brackets: '=a', + indices: '=a', + repeat: '=a', + }, + }, + { + input: 'a==a', + with_empty_keys: { a: '=a' }, + no_empty_keys: { a: '=a' }, + stringify_output: { + brackets: 'a==a', + indices: 'a==a', + repeat: 'a==a', + }, + }, + { + input: '=&a[]=b', + with_empty_keys: { '': '', a: ['b'] }, + stringify_output: { + brackets: '=&a[]=b', + indices: '=&a[0]=b', + repeat: '=&a=b', + }, + no_empty_keys: { a: ['b'] }, + }, + { + input: '=&a[]=b&a[]=c&a[2]=d', + with_empty_keys: { '': '', a: ['b', 'c', 'd'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c&a[]=d', + indices: '=&a[0]=b&a[1]=c&a[2]=d', + repeat: '=&a=b&a=c&a=d', + }, + no_empty_keys: { a: ['b', 'c', 'd'] }, + }, + { + input: '=a&=b', + with_empty_keys: { '': ['a', 'b'] }, + stringify_output: { + brackets: '[]=a&[]=b', + indices: '[0]=a&[1]=b', + repeat: '=a&=b', + }, + no_empty_keys: {}, + }, + { + input: '=a&foo=b', + with_empty_keys: { '': 'a', foo: 'b' }, + no_empty_keys: { foo: 'b' }, + stringify_output: { + brackets: '=a&foo=b', + indices: '=a&foo=b', + repeat: '=a&foo=b', + }, + }, + { + input: 'a[]=b&a=c&=', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: 'a[]=b&a=c&=', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: 'a[0]=b&a=c&=', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: 'a=b&a[]=c&=', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: 'a=b&a[0]=c&=', + with_empty_keys: { '': '', a: ['b', 'c'] }, + stringify_output: { + brackets: '=&a[]=b&a[]=c', + indices: '=&a[0]=b&a[1]=c', + repeat: '=&a=b&a=c', + }, + no_empty_keys: { a: ['b', 'c'] }, + }, + { + input: '[]=a&[]=b& []=1', + with_empty_keys: { '': ['a', 'b'], ' ': ['1'] }, + stringify_output: { + brackets: '[]=a&[]=b& []=1', + indices: '[0]=a&[1]=b& [0]=1', + repeat: '=a&=b& =1', + }, + no_empty_keys: { 0: 'a', 1: 'b', ' ': ['1'] }, + }, + { + input: '[0]=a&[1]=b&a[0]=1&a[1]=2', + with_empty_keys: { '': ['a', 'b'], a: ['1', '2'] }, + no_empty_keys: { 0: 'a', 1: 'b', a: ['1', '2'] }, + stringify_output: { + brackets: '[]=a&[]=b&a[]=1&a[]=2', + indices: '[0]=a&[1]=b&a[0]=1&a[1]=2', + repeat: '=a&=b&a=1&a=2', + }, + }, + { + input: '[deep]=a&[deep]=2', + with_empty_keys: { '': { deep: ['a', '2'] } }, + stringify_output: { + brackets: '[deep][]=a&[deep][]=2', + indices: '[deep][0]=a&[deep][1]=2', + repeat: '[deep]=a&[deep]=2', + }, + no_empty_keys: { deep: ['a', '2'] }, + }, + { + input: '%5B0%5D=a&%5B1%5D=b', + with_empty_keys: { '': ['a', 'b'] }, + stringify_output: { + brackets: '[]=a&[]=b', + indices: '[0]=a&[1]=b', + repeat: '=a&=b', + }, + no_empty_keys: { 0: 'a', 1: 'b' }, + }, +] satisfies { + input: string; + with_empty_keys: Record; + stringify_output: { + brackets: string; + indices: string; + repeat: string; + }; + no_empty_keys: Record; +}[]; diff --git a/tests/qs/stringify.test.ts b/tests/qs/stringify.test.ts new file mode 100644 index 0000000..75f85f3 --- /dev/null +++ b/tests/qs/stringify.test.ts @@ -0,0 +1,2232 @@ +import iconv from 'iconv-lite'; +import { stringify } from 'langsmith/internal/qs'; +import { encode } from 'langsmith/internal/qs/utils'; +import { StringifyOptions } from 'langsmith/internal/qs/types'; +import { empty_test_cases } from './empty-keys-cases'; +import assert from 'assert'; + +describe('stringify()', function () { + test('stringifies a querystring object', function () { + expect(stringify({ a: 'b' })).toBe('a=b'); + expect(stringify({ a: 1 })).toBe('a=1'); + expect(stringify({ a: 1, b: 2 })).toBe('a=1&b=2'); + expect(stringify({ a: 'A_Z' })).toBe('a=A_Z'); + expect(stringify({ a: '€' })).toBe('a=%E2%82%AC'); + expect(stringify({ a: '' })).toBe('a=%EE%80%80'); + expect(stringify({ a: 'א' })).toBe('a=%D7%90'); + expect(stringify({ a: '𐐷' })).toBe('a=%F0%90%90%B7'); + }); + + test('stringifies falsy values', function () { + expect(stringify(undefined)).toBe(''); + expect(stringify(null)).toBe(''); + expect(stringify(null, { strictNullHandling: true })).toBe(''); + expect(stringify(false)).toBe(''); + expect(stringify(0)).toBe(''); + }); + + test('stringifies symbols', function () { + expect(stringify(Symbol.iterator)).toBe(''); + expect(stringify([Symbol.iterator])).toBe('0=Symbol%28Symbol.iterator%29'); + expect(stringify({ a: Symbol.iterator })).toBe('a=Symbol%28Symbol.iterator%29'); + expect(stringify({ a: [Symbol.iterator] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe( + 'a[]=Symbol%28Symbol.iterator%29', + ); + }); + + test('stringifies bigints', function () { + var three = BigInt(3); + // @ts-expect-error + var encodeWithN = function (value, defaultEncoder, charset) { + var result = defaultEncoder(value, defaultEncoder, charset); + return typeof value === 'bigint' ? result + 'n' : result; + }; + + expect(stringify(three)).toBe(''); + expect(stringify([three])).toBe('0=3'); + expect(stringify([three], { encoder: encodeWithN })).toBe('0=3n'); + expect(stringify({ a: three })).toBe('a=3'); + expect(stringify({ a: three }, { encoder: encodeWithN })).toBe('a=3n'); + expect(stringify({ a: [three] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe('a[]=3'); + expect( + stringify({ a: [three] }, { encodeValuesOnly: true, encoder: encodeWithN, arrayFormat: 'brackets' }), + ).toBe('a[]=3n'); + }); + + test('encodes dot in key of object when encodeDotInKeys and allowDots is provided', function () { + expect( + stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, { allowDots: false, encodeDotInKeys: false }), + ).toBe('name.obj%5Bfirst%5D=John&name.obj%5Blast%5D=Doe'); + expect( + stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, { allowDots: true, encodeDotInKeys: false }), + ).toBe('name.obj.first=John&name.obj.last=Doe'); + expect( + stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, { allowDots: false, encodeDotInKeys: true }), + ).toBe('name%252Eobj%5Bfirst%5D=John&name%252Eobj%5Blast%5D=Doe'); + expect( + stringify({ 'name.obj': { first: 'John', last: 'Doe' } }, { allowDots: true, encodeDotInKeys: true }), + ).toBe('name%252Eobj.first=John&name%252Eobj.last=Doe'); + + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { allowDots: false, encodeDotInKeys: false }, + // ), + // 'name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe', + // 'with allowDots false and encodeDotInKeys false', + // ); + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { allowDots: true, encodeDotInKeys: false }, + // ), + // 'name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe', + // 'with allowDots false and encodeDotInKeys false', + // ); + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { allowDots: false, encodeDotInKeys: true }, + // ), + // 'name%252Eobj%252Esubobject%5Bfirst.godly.name%5D=John&name%252Eobj%252Esubobject%5Blast%5D=Doe', + // 'with allowDots false and encodeDotInKeys true', + // ); + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { allowDots: true, encodeDotInKeys: true }, + // ), + // 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + // 'with allowDots true and encodeDotInKeys true', + // ); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: false }, + ), + ).toBe('name.obj.subobject%5Bfirst.godly.name%5D=John&name.obj.subobject%5Blast%5D=Doe'); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: false }, + ), + ).toBe('name.obj.subobject.first.godly.name=John&name.obj.subobject.last=Doe'); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: false, encodeDotInKeys: true }, + ), + ).toBe('name%252Eobj%252Esubobject%5Bfirst.godly.name%5D=John&name%252Eobj%252Esubobject%5Blast%5D=Doe'); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: true }, + ), + ).toBe('name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe'); + }); + + test('should encode dot in key of object, and automatically set allowDots to `true` when encodeDotInKeys is true and allowDots in undefined', function () { + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { encodeDotInKeys: true }, + // ), + // 'name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe', + // 'with allowDots undefined and encodeDotInKeys true', + // ); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { encodeDotInKeys: true }, + ), + ).toBe('name%252Eobj%252Esubobject.first%252Egodly%252Ename=John&name%252Eobj%252Esubobject.last=Doe'); + }); + + test('should encode dot in key of object when encodeDotInKeys and allowDots is provided, and nothing else when encodeValuesOnly is provided', function () { + // st.equal( + // stringify( + // { 'name.obj': { first: 'John', last: 'Doe' } }, + // { + // encodeDotInKeys: true, + // allowDots: true, + // encodeValuesOnly: true, + // }, + // ), + // 'name%2Eobj.first=John&name%2Eobj.last=Doe', + // ); + expect( + stringify( + { 'name.obj': { first: 'John', last: 'Doe' } }, + { + encodeDotInKeys: true, + allowDots: true, + encodeValuesOnly: true, + }, + ), + ).toBe('name%2Eobj.first=John&name%2Eobj.last=Doe'); + + // st.equal( + // stringify( + // { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + // { allowDots: true, encodeDotInKeys: true, encodeValuesOnly: true }, + // ), + // 'name%2Eobj%2Esubobject.first%2Egodly%2Ename=John&name%2Eobj%2Esubobject.last=Doe', + // ); + expect( + stringify( + { 'name.obj.subobject': { 'first.godly.name': 'John', last: 'Doe' } }, + { allowDots: true, encodeDotInKeys: true, encodeValuesOnly: true }, + ), + ).toBe('name%2Eobj%2Esubobject.first%2Egodly%2Ename=John&name%2Eobj%2Esubobject.last=Doe'); + }); + + test('throws when `commaRoundTrip` is not a boolean', function () { + // st['throws']( + // function () { + // stringify({}, { commaRoundTrip: 'not a boolean' }); + // }, + // TypeError, + // 'throws when `commaRoundTrip` is not a boolean', + // ); + expect(() => { + // @ts-expect-error + stringify({}, { commaRoundTrip: 'not a boolean' }); + }).toThrow(TypeError); + }); + + test('throws when `encodeDotInKeys` is not a boolean', function () { + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 'foobar' }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 'foobar' }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 0 }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { encodeDotInKeys: 0 }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { encodeDotInKeys: NaN }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { encodeDotInKeys: NaN }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { encodeDotInKeys: null }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { encodeDotInKeys: null }); + }).toThrow(TypeError); + }); + + test('adds query prefix', function () { + // st.equal(stringify({ a: 'b' }, { addQueryPrefix: true }), '?a=b'); + expect(stringify({ a: 'b' }, { addQueryPrefix: true })).toBe('?a=b'); + }); + + test('with query prefix, outputs blank string given an empty object', function () { + // st.equal(stringify({}, { addQueryPrefix: true }), ''); + expect(stringify({}, { addQueryPrefix: true })).toBe(''); + }); + + test('stringifies nested falsy values', function () { + // st.equal(stringify({ a: { b: { c: null } } }), 'a%5Bb%5D%5Bc%5D='); + // st.equal( + // stringify({ a: { b: { c: null } } }, { strictNullHandling: true }), + // 'a%5Bb%5D%5Bc%5D', + // ); + // st.equal(stringify({ a: { b: { c: false } } }), 'a%5Bb%5D%5Bc%5D=false'); + expect(stringify({ a: { b: { c: null } } })).toBe('a%5Bb%5D%5Bc%5D='); + expect(stringify({ a: { b: { c: null } } }, { strictNullHandling: true })).toBe('a%5Bb%5D%5Bc%5D'); + expect(stringify({ a: { b: { c: false } } })).toBe('a%5Bb%5D%5Bc%5D=false'); + }); + + test('stringifies a nested object', function () { + // st.equal(stringify({ a: { b: 'c' } }), 'a%5Bb%5D=c'); + // st.equal(stringify({ a: { b: { c: { d: 'e' } } } }), 'a%5Bb%5D%5Bc%5D%5Bd%5D=e'); + expect(stringify({ a: { b: 'c' } })).toBe('a%5Bb%5D=c'); + expect(stringify({ a: { b: { c: { d: 'e' } } } })).toBe('a%5Bb%5D%5Bc%5D%5Bd%5D=e'); + }); + + test('`allowDots` option: stringifies a nested object with dots notation', function () { + // st.equal(stringify({ a: { b: 'c' } }, { allowDots: true }), 'a.b=c'); + // st.equal(stringify({ a: { b: { c: { d: 'e' } } } }, { allowDots: true }), 'a.b.c.d=e'); + expect(stringify({ a: { b: 'c' } }, { allowDots: true })).toBe('a.b=c'); + expect(stringify({ a: { b: { c: { d: 'e' } } } }, { allowDots: true })).toBe('a.b.c.d=e'); + }); + + test('stringifies an array value', function () { + // st.equal( + // stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'indices' }), + // 'a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d', + // 'indices => indices', + // ); + // st.equal( + // stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'brackets' }), + // 'a%5B%5D=b&a%5B%5D=c&a%5B%5D=d', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'comma' }), + // 'a=b%2Cc%2Cd', + // 'comma => comma', + // ); + // st.equal( + // stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'comma', commaRoundTrip: true }), + // 'a=b%2Cc%2Cd', + // 'comma round trip => comma', + // ); + // st.equal( + // stringify({ a: ['b', 'c', 'd'] }), + // 'a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d', + // 'default => indices', + // ); + expect(stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'indices' })).toBe( + 'a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d', + ); + expect(stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'brackets' })).toBe( + 'a%5B%5D=b&a%5B%5D=c&a%5B%5D=d', + ); + expect(stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'comma' })).toBe('a=b%2Cc%2Cd'); + expect(stringify({ a: ['b', 'c', 'd'] }, { arrayFormat: 'comma', commaRoundTrip: true })).toBe( + 'a=b%2Cc%2Cd', + ); + expect(stringify({ a: ['b', 'c', 'd'] })).toBe('a%5B0%5D=b&a%5B1%5D=c&a%5B2%5D=d'); + }); + + test('`skipNulls` option', function () { + // st.equal( + // stringify({ a: 'b', c: null }, { skipNulls: true }), + // 'a=b', + // 'omits nulls when asked', + // ); + expect(stringify({ a: 'b', c: null }, { skipNulls: true })).toBe('a=b'); + + // st.equal( + // stringify({ a: { b: 'c', d: null } }, { skipNulls: true }), + // 'a%5Bb%5D=c', + // 'omits nested nulls when asked', + // ); + expect(stringify({ a: { b: 'c', d: null } }, { skipNulls: true })).toBe('a%5Bb%5D=c'); + }); + + test('omits array indices when asked', function () { + // st.equal(stringify({ a: ['b', 'c', 'd'] }, { indices: false }), 'a=b&a=c&a=d'); + expect(stringify({ a: ['b', 'c', 'd'] }, { indices: false })).toBe('a=b&a=c&a=d'); + }); + + test('omits object key/value pair when value is empty array', function () { + // st.equal(stringify({ a: [], b: 'zz' }), 'b=zz'); + expect(stringify({ a: [], b: 'zz' })).toBe('b=zz'); + }); + + test('should not omit object key/value pair when value is empty array and when asked', function () { + // st.equal(stringify({ a: [], b: 'zz' }), 'b=zz'); + // st.equal(stringify({ a: [], b: 'zz' }, { allowEmptyArrays: false }), 'b=zz'); + // st.equal(stringify({ a: [], b: 'zz' }, { allowEmptyArrays: true }), 'a[]&b=zz'); + expect(stringify({ a: [], b: 'zz' })).toBe('b=zz'); + expect(stringify({ a: [], b: 'zz' }, { allowEmptyArrays: false })).toBe('b=zz'); + expect(stringify({ a: [], b: 'zz' }, { allowEmptyArrays: true })).toBe('a[]&b=zz'); + }); + + test('should throw when allowEmptyArrays is not of type boolean', function () { + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { allowEmptyArrays: 'foobar' }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { allowEmptyArrays: 'foobar' }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { allowEmptyArrays: 0 }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { allowEmptyArrays: 0 }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { allowEmptyArrays: NaN }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { allowEmptyArrays: NaN }); + }).toThrow(TypeError); + + // st['throws'](function () { + // stringify({ a: [], b: 'zz' }, { allowEmptyArrays: null }); + // }, TypeError); + expect(() => { + // @ts-expect-error + stringify({ a: [], b: 'zz' }, { allowEmptyArrays: null }); + }).toThrow(TypeError); + }); + + test('allowEmptyArrays + strictNullHandling', function () { + // st.equal( + // stringify({ testEmptyArray: [] }, { strictNullHandling: true, allowEmptyArrays: true }), + // 'testEmptyArray[]', + // ); + expect(stringify({ testEmptyArray: [] }, { strictNullHandling: true, allowEmptyArrays: true })).toBe( + 'testEmptyArray[]', + ); + }); + + describe('stringifies an array value with one item vs multiple items', function () { + test('non-array item', function () { + // s2t.equal( + // stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + // 'a=c', + // ); + // s2t.equal( + // stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + // 'a=c', + // ); + // s2t.equal(stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'comma' }), 'a=c'); + // s2t.equal(stringify({ a: 'c' }, { encodeValuesOnly: true }), 'a=c'); + expect(stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe('a=c'); + expect(stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe('a=c'); + expect(stringify({ a: 'c' }, { encodeValuesOnly: true, arrayFormat: 'comma' })).toBe('a=c'); + expect(stringify({ a: 'c' }, { encodeValuesOnly: true })).toBe('a=c'); + }); + + test('array with a single item', function () { + // s2t.equal( + // stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + // 'a[0]=c', + // ); + // s2t.equal( + // stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + // 'a[]=c', + // ); + // s2t.equal( + // stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'comma' }), + // 'a=c', + // ); + // s2t.equal( + // stringify( + // { a: ['c'] }, + // { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }, + // ), + // 'a[]=c', + // ); // so it parses back as an array + // s2t.equal(stringify({ a: ['c'] }, { encodeValuesOnly: true }), 'a[0]=c'); + expect(stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe('a[0]=c'); + expect(stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe('a[]=c'); + expect(stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'comma' })).toBe('a=c'); + expect( + stringify({ a: ['c'] }, { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }), + ).toBe('a[]=c'); + expect(stringify({ a: ['c'] }, { encodeValuesOnly: true })).toBe('a[0]=c'); + }); + + test('array with multiple items', function () { + // s2t.equal( + // stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + // 'a[0]=c&a[1]=d', + // ); + // s2t.equal( + // stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + // 'a[]=c&a[]=d', + // ); + // s2t.equal( + // stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'comma' }), + // 'a=c,d', + // ); + // s2t.equal( + // stringify( + // { a: ['c', 'd'] }, + // { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }, + // ), + // 'a=c,d', + // ); + // s2t.equal(stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true }), 'a[0]=c&a[1]=d'); + expect(stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe( + 'a[0]=c&a[1]=d', + ); + expect(stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe( + 'a[]=c&a[]=d', + ); + expect(stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'comma' })).toBe('a=c,d'); + expect( + stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }), + ).toBe('a=c,d'); + expect(stringify({ a: ['c', 'd'] }, { encodeValuesOnly: true })).toBe('a[0]=c&a[1]=d'); + }); + + test('array with multiple items with a comma inside', function () { + // s2t.equal( + // stringify({ a: ['c,d', 'e'] }, { encodeValuesOnly: true, arrayFormat: 'comma' }), + // 'a=c%2Cd,e', + // ); + // s2t.equal(stringify({ a: ['c,d', 'e'] }, { arrayFormat: 'comma' }), 'a=c%2Cd%2Ce'); + expect(stringify({ a: ['c,d', 'e'] }, { encodeValuesOnly: true, arrayFormat: 'comma' })).toBe( + 'a=c%2Cd,e', + ); + expect(stringify({ a: ['c,d', 'e'] }, { arrayFormat: 'comma' })).toBe('a=c%2Cd%2Ce'); + + // s2t.equal( + // stringify( + // { a: ['c,d', 'e'] }, + // { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }, + // ), + // 'a=c%2Cd,e', + // ); + // s2t.equal( + // stringify({ a: ['c,d', 'e'] }, { arrayFormat: 'comma', commaRoundTrip: true }), + // 'a=c%2Cd%2Ce', + // ); + expect( + stringify( + { a: ['c,d', 'e'] }, + { encodeValuesOnly: true, arrayFormat: 'comma', commaRoundTrip: true }, + ), + ).toBe('a=c%2Cd,e'); + expect(stringify({ a: ['c,d', 'e'] }, { arrayFormat: 'comma', commaRoundTrip: true })).toBe( + 'a=c%2Cd%2Ce', + ); + }); + }); + + test('stringifies a nested array value', function () { + expect(stringify({ a: { b: ['c', 'd'] } }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe( + 'a[b][0]=c&a[b][1]=d', + ); + expect(stringify({ a: { b: ['c', 'd'] } }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe( + 'a[b][]=c&a[b][]=d', + ); + expect(stringify({ a: { b: ['c', 'd'] } }, { encodeValuesOnly: true, arrayFormat: 'comma' })).toBe( + 'a[b]=c,d', + ); + expect(stringify({ a: { b: ['c', 'd'] } }, { encodeValuesOnly: true })).toBe('a[b][0]=c&a[b][1]=d'); + }); + + test('stringifies comma and empty array values', function () { + // st.equal( + // stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'indices' }), + // 'a[0]=,&a[1]=&a[2]=c,d%', + // ); + // st.equal( + // stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'brackets' }), + // 'a[]=,&a[]=&a[]=c,d%', + // ); + // st.equal( + // stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'comma' }), + // 'a=,,,c,d%', + // ); + // st.equal( + // stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'repeat' }), + // 'a=,&a=&a=c,d%', + // ); + expect(stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'indices' })).toBe( + 'a[0]=,&a[1]=&a[2]=c,d%', + ); + expect(stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'brackets' })).toBe( + 'a[]=,&a[]=&a[]=c,d%', + ); + expect(stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'comma' })).toBe('a=,,,c,d%'); + expect(stringify({ a: [',', '', 'c,d%'] }, { encode: false, arrayFormat: 'repeat' })).toBe( + 'a=,&a=&a=c,d%', + ); + + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a[0]=%2C&a[1]=&a[2]=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a[]=%2C&a[]=&a[]=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'comma' }, + // ), + // 'a=%2C,,c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a=%2C&a=&a=c%2Cd%25', + // ); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'indices' }), + ).toBe('a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe('a[]=%2C&a[]=&a[]=c%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'comma' }), + ).toBe('a=%2C%2C%2Cc%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }), + ).toBe('a=%2C&a=&a=c%2Cd%25'); + + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'indices' }, + // ), + // 'a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'brackets' }, + // ), + // 'a%5B%5D=%2C&a%5B%5D=&a%5B%5D=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'comma' }, + // ), + // 'a=%2C%2C%2Cc%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: [',', '', 'c,d%'] }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }, + // ), + // 'a=%2C&a=&a=c%2Cd%25', + // ); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }), + ).toBe('a=%2C&a=&a=c%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'indices' }), + ).toBe('a%5B0%5D=%2C&a%5B1%5D=&a%5B2%5D=c%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe('a[]=%2C&a[]=&a[]=c%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'comma' }), + ).toBe('a=%2C%2C%2Cc%2Cd%25'); + expect( + stringify({ a: [',', '', 'c,d%'] }, { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }), + ).toBe('a=%2C&a=&a=c%2Cd%25'); + }); + + test('stringifies comma and empty non-array values', function () { + // st.equal( + // stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'indices' }), + // 'a=,&b=&c=c,d%', + // ); + // st.equal( + // stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'brackets' }), + // 'a=,&b=&c=c,d%', + // ); + // st.equal( + // stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'comma' }), + // 'a=,&b=&c=c,d%', + // ); + // st.equal( + // stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'repeat' }), + // 'a=,&b=&c=c,d%', + // ); + expect(stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'indices' })).toBe( + 'a=,&b=&c=c,d%', + ); + expect(stringify({ a: ',', b: '', c: 'c,d%' }, { encode: false, arrayFormat: 'brackets' })).toBe( + 'a=,&b=&c=c,d%', + ); + + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'comma' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: true, arrayFormat: 'indices' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: true, arrayFormat: 'brackets' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify({ a: ',', b: '', c: 'c,d%' }, { encode: true, encodeValuesOnly: true, arrayFormat: 'comma' }), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: true, arrayFormat: 'repeat' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'indices' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'brackets' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'comma' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + // st.equal( + // stringify( + // { a: ',', b: '', c: 'c,d%' }, + // { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }, + // ), + // 'a=%2C&b=&c=c%2Cd%25', + // ); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: false, arrayFormat: 'indices' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: false, arrayFormat: 'brackets' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: false, arrayFormat: 'comma' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + expect( + stringify( + { a: ',', b: '', c: 'c,d%' }, + { encode: true, encodeValuesOnly: false, arrayFormat: 'repeat' }, + ), + ).toBe('a=%2C&b=&c=c%2Cd%25'); + }); + + test('stringifies a nested array value with dots notation', function () { + // st.equal( + // stringify( + // { a: { b: ['c', 'd'] } }, + // { allowDots: true, encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a.b[0]=c&a.b[1]=d', + // 'indices: stringifies with dots + indices', + // ); + // st.equal( + // stringify( + // { a: { b: ['c', 'd'] } }, + // { allowDots: true, encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a.b[]=c&a.b[]=d', + // 'brackets: stringifies with dots + brackets', + // ); + // st.equal( + // stringify( + // { a: { b: ['c', 'd'] } }, + // { allowDots: true, encodeValuesOnly: true, arrayFormat: 'comma' }, + // ), + // 'a.b=c,d', + // 'comma: stringifies with dots + comma', + // ); + // st.equal( + // stringify({ a: { b: ['c', 'd'] } }, { allowDots: true, encodeValuesOnly: true }), + // 'a.b[0]=c&a.b[1]=d', + // 'default: stringifies with dots + indices', + // ); + expect( + stringify( + { a: { b: ['c', 'd'] } }, + { allowDots: true, encodeValuesOnly: true, arrayFormat: 'indices' }, + ), + ).toBe('a.b[0]=c&a.b[1]=d'); + expect( + stringify( + { a: { b: ['c', 'd'] } }, + { allowDots: true, encodeValuesOnly: true, arrayFormat: 'brackets' }, + ), + ).toBe('a.b[]=c&a.b[]=d'); + expect( + stringify({ a: { b: ['c', 'd'] } }, { allowDots: true, encodeValuesOnly: true, arrayFormat: 'comma' }), + ).toBe('a.b=c,d'); + expect(stringify({ a: { b: ['c', 'd'] } }, { allowDots: true, encodeValuesOnly: true })).toBe( + 'a.b[0]=c&a.b[1]=d', + ); + }); + + test('stringifies an object inside an array', function () { + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'indices', encodeValuesOnly: true }), + // 'a[0][b]=c', + // 'indices => indices', + // ); + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'repeat', encodeValuesOnly: true }), + // 'a[b]=c', + // 'repeat => repeat', + // ); + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'brackets', encodeValuesOnly: true }), + // 'a[][b]=c', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { encodeValuesOnly: true }), + // 'a[0][b]=c', + // 'default => indices', + // ); + expect(stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'indices', encodeValuesOnly: true })).toBe( + 'a[0][b]=c', + ); + expect(stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'repeat', encodeValuesOnly: true })).toBe('a[b]=c'); + expect(stringify({ a: [{ b: 'c' }] }, { arrayFormat: 'brackets', encodeValuesOnly: true })).toBe( + 'a[][b]=c', + ); + expect(stringify({ a: [{ b: 'c' }] }, { encodeValuesOnly: true })).toBe('a[0][b]=c'); + + // st.equal( + // stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'indices', encodeValuesOnly: true }), + // 'a[0][b][c][0]=1', + // 'indices => indices', + // ); + // st.equal( + // stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'repeat', encodeValuesOnly: true }), + // 'a[b][c]=1', + // 'repeat => repeat', + // ); + // st.equal( + // stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'brackets', encodeValuesOnly: true }), + // 'a[][b][c][]=1', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: [{ b: { c: [1] } }] }, { encodeValuesOnly: true }), + // 'a[0][b][c][0]=1', + // 'default => indices', + // ); + expect(stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'indices', encodeValuesOnly: true })).toBe( + 'a[0][b][c][0]=1', + ); + expect(stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'repeat', encodeValuesOnly: true })).toBe( + 'a[b][c]=1', + ); + expect(stringify({ a: [{ b: { c: [1] } }] }, { arrayFormat: 'brackets', encodeValuesOnly: true })).toBe( + 'a[][b][c][]=1', + ); + expect(stringify({ a: [{ b: { c: [1] } }] }, { encodeValuesOnly: true })).toBe('a[0][b][c][0]=1'); + }); + + test('stringifies an array with mixed objects and primitives', function () { + // st.equal( + // stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + // 'a[0][b]=1&a[1]=2&a[2]=3', + // 'indices => indices', + // ); + // st.equal( + // stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + // 'a[][b]=1&a[]=2&a[]=3', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'comma' }), + // '???', + // 'brackets => brackets', + // { skip: 'TODO: figure out what this should do' }, + // ); + // st.equal( + // stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true }), + // 'a[0][b]=1&a[1]=2&a[2]=3', + // 'default => indices', + // ); + expect(stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe( + 'a[0][b]=1&a[1]=2&a[2]=3', + ); + expect(stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe( + 'a[][b]=1&a[]=2&a[]=3', + ); + // !Skipped: Figure out what this should do + // expect( + // stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true, arrayFormat: 'comma' }), + // ).toBe('???'); + expect(stringify({ a: [{ b: 1 }, 2, 3] }, { encodeValuesOnly: true })).toBe('a[0][b]=1&a[1]=2&a[2]=3'); + }); + + test('stringifies an object inside an array with dots notation', function () { + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { allowDots: true, encode: false, arrayFormat: 'indices' }), + // 'a[0].b=c', + // 'indices => indices', + // ); + // st.equal( + // stringify( + // { a: [{ b: 'c' }] }, + // { allowDots: true, encode: false, arrayFormat: 'brackets' }, + // ), + // 'a[].b=c', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: [{ b: 'c' }] }, { allowDots: true, encode: false }), + // 'a[0].b=c', + // 'default => indices', + // ); + expect(stringify({ a: [{ b: 'c' }] }, { allowDots: true, encode: false, arrayFormat: 'indices' })).toBe( + 'a[0].b=c', + ); + expect(stringify({ a: [{ b: 'c' }] }, { allowDots: true, encode: false, arrayFormat: 'brackets' })).toBe( + 'a[].b=c', + ); + expect(stringify({ a: [{ b: 'c' }] }, { allowDots: true, encode: false })).toBe('a[0].b=c'); + + // st.equal( + // stringify( + // { a: [{ b: { c: [1] } }] }, + // { allowDots: true, encode: false, arrayFormat: 'indices' }, + // ), + // 'a[0].b.c[0]=1', + // 'indices => indices', + // ); + // st.equal( + // stringify( + // { a: [{ b: { c: [1] } }] }, + // { allowDots: true, encode: false, arrayFormat: 'brackets' }, + // ), + // 'a[].b.c[]=1', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: [{ b: { c: [1] } }] }, { allowDots: true, encode: false }), + // 'a[0].b.c[0]=1', + // 'default => indices', + // ); + expect( + stringify({ a: [{ b: { c: [1] } }] }, { allowDots: true, encode: false, arrayFormat: 'indices' }), + ).toBe('a[0].b.c[0]=1'); + expect( + stringify({ a: [{ b: { c: [1] } }] }, { allowDots: true, encode: false, arrayFormat: 'brackets' }), + ).toBe('a[].b.c[]=1'); + expect(stringify({ a: [{ b: { c: [1] } }] }, { allowDots: true, encode: false })).toBe('a[0].b.c[0]=1'); + }); + + test('does not omit object keys when indices = false', function () { + // st.equal(stringify({ a: [{ b: 'c' }] }, { indices: false }), 'a%5Bb%5D=c'); + expect(stringify({ a: [{ b: 'c' }] }, { indices: false })).toBe('a%5Bb%5D=c'); + }); + + test('uses indices notation for arrays when indices=true', function () { + // st.equal(stringify({ a: ['b', 'c'] }, { indices: true }), 'a%5B0%5D=b&a%5B1%5D=c'); + expect(stringify({ a: ['b', 'c'] }, { indices: true })).toBe('a%5B0%5D=b&a%5B1%5D=c'); + }); + + test('uses indices notation for arrays when no arrayFormat is specified', function () { + // st.equal(stringify({ a: ['b', 'c'] }), 'a%5B0%5D=b&a%5B1%5D=c'); + expect(stringify({ a: ['b', 'c'] })).toBe('a%5B0%5D=b&a%5B1%5D=c'); + }); + + test('uses indices notation for arrays when arrayFormat=indices', function () { + // st.equal(stringify({ a: ['b', 'c'] }, { arrayFormat: 'indices' }), 'a%5B0%5D=b&a%5B1%5D=c'); + expect(stringify({ a: ['b', 'c'] }, { arrayFormat: 'indices' })).toBe('a%5B0%5D=b&a%5B1%5D=c'); + }); + + test('uses repeat notation for arrays when arrayFormat=repeat', function () { + // st.equal(stringify({ a: ['b', 'c'] }, { arrayFormat: 'repeat' }), 'a=b&a=c'); + expect(stringify({ a: ['b', 'c'] }, { arrayFormat: 'repeat' })).toBe('a=b&a=c'); + }); + + test('uses brackets notation for arrays when arrayFormat=brackets', function () { + // st.equal(stringify({ a: ['b', 'c'] }, { arrayFormat: 'brackets' }), 'a%5B%5D=b&a%5B%5D=c'); + expect(stringify({ a: ['b', 'c'] }, { arrayFormat: 'brackets' })).toBe('a%5B%5D=b&a%5B%5D=c'); + }); + + test('stringifies a complicated object', function () { + // st.equal(stringify({ a: { b: 'c', d: 'e' } }), 'a%5Bb%5D=c&a%5Bd%5D=e'); + expect(stringify({ a: { b: 'c', d: 'e' } })).toBe('a%5Bb%5D=c&a%5Bd%5D=e'); + }); + + test('stringifies an empty value', function () { + // st.equal(stringify({ a: '' }), 'a='); + // st.equal(stringify({ a: null }, { strictNullHandling: true }), 'a'); + expect(stringify({ a: '' })).toBe('a='); + expect(stringify({ a: null }, { strictNullHandling: true })).toBe('a'); + + // st.equal(stringify({ a: '', b: '' }), 'a=&b='); + // st.equal(stringify({ a: null, b: '' }, { strictNullHandling: true }), 'a&b='); + expect(stringify({ a: '', b: '' })).toBe('a=&b='); + expect(stringify({ a: null, b: '' }, { strictNullHandling: true })).toBe('a&b='); + + // st.equal(stringify({ a: { b: '' } }), 'a%5Bb%5D='); + // st.equal(stringify({ a: { b: null } }, { strictNullHandling: true }), 'a%5Bb%5D'); + // st.equal(stringify({ a: { b: null } }, { strictNullHandling: false }), 'a%5Bb%5D='); + expect(stringify({ a: { b: '' } })).toBe('a%5Bb%5D='); + expect(stringify({ a: { b: null } }, { strictNullHandling: true })).toBe('a%5Bb%5D'); + expect(stringify({ a: { b: null } }, { strictNullHandling: false })).toBe('a%5Bb%5D='); + }); + + test('stringifies an empty array in different arrayFormat', function () { + // st.equal(stringify({ a: [], b: [null], c: 'c' }, { encode: false }), 'b[0]=&c=c'); + expect(stringify({ a: [], b: [null], c: 'c' }, { encode: false })).toBe('b[0]=&c=c'); + // arrayFormat default + // st.equal( + // stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'indices' }), + // 'b[0]=&c=c', + // ); + // st.equal( + // stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'brackets' }), + // 'b[]=&c=c', + // ); + // st.equal( + // stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'repeat' }), + // 'b=&c=c', + // ); + // st.equal( + // stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'comma' }), + // 'b=&c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'comma', commaRoundTrip: true }, + // ), + // 'b[]=&c=c', + // ); + expect(stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'indices' })).toBe( + 'b[0]=&c=c', + ); + expect(stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'brackets' })).toBe( + 'b[]=&c=c', + ); + expect(stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'repeat' })).toBe('b=&c=c'); + expect(stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'comma' })).toBe('b=&c=c'); + expect( + stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'comma', commaRoundTrip: true }), + ).toBe('b[]=&c=c'); + + // with strictNullHandling + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'indices', strictNullHandling: true }, + // ), + // 'b[0]&c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'brackets', strictNullHandling: true }, + // ), + // 'b[]&c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'repeat', strictNullHandling: true }, + // ), + // 'b&c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'comma', strictNullHandling: true }, + // ), + // 'b&c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'comma', strictNullHandling: true, commaRoundTrip: true }, + // ), + // 'b[]&c=c', + // ); + + expect( + stringify( + { a: [], b: [null], c: 'c' }, + { encode: false, arrayFormat: 'indices', strictNullHandling: true }, + ), + ).toBe('b[0]&c=c'); + expect( + stringify( + { a: [], b: [null], c: 'c' }, + { encode: false, arrayFormat: 'brackets', strictNullHandling: true }, + ), + ).toBe('b[]&c=c'); + expect( + stringify( + { a: [], b: [null], c: 'c' }, + { encode: false, arrayFormat: 'repeat', strictNullHandling: true }, + ), + ).toBe('b&c=c'); + expect( + stringify( + { a: [], b: [null], c: 'c' }, + { encode: false, arrayFormat: 'comma', strictNullHandling: true }, + ), + ).toBe('b&c=c'); + expect( + stringify( + { a: [], b: [null], c: 'c' }, + { encode: false, arrayFormat: 'comma', strictNullHandling: true, commaRoundTrip: true }, + ), + ).toBe('b[]&c=c'); + + // with skipNulls + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'indices', skipNulls: true }, + // ), + // 'c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'brackets', skipNulls: true }, + // ), + // 'c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'repeat', skipNulls: true }, + // ), + // 'c=c', + // ); + // st.equal( + // stringify( + // { a: [], b: [null], c: 'c' }, + // { encode: false, arrayFormat: 'comma', skipNulls: true }, + // ), + // 'c=c', + // ); + expect( + stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'indices', skipNulls: true }), + ).toBe('c=c'); + expect( + stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'brackets', skipNulls: true }), + ).toBe('c=c'); + expect( + stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'repeat', skipNulls: true }), + ).toBe('c=c'); + expect( + stringify({ a: [], b: [null], c: 'c' }, { encode: false, arrayFormat: 'comma', skipNulls: true }), + ).toBe('c=c'); + }); + + test('stringifies a null object', function () { + var obj = Object.create(null); + obj.a = 'b'; + // st.equal(stringify(obj), 'a=b'); + expect(stringify(obj)).toBe('a=b'); + }); + + test('returns an empty string for invalid input', function () { + // st.equal(stringify(undefined), ''); + // st.equal(stringify(false), ''); + // st.equal(stringify(null), ''); + // st.equal(stringify(''), ''); + expect(stringify(undefined)).toBe(''); + expect(stringify(false)).toBe(''); + expect(stringify(null)).toBe(''); + expect(stringify('')).toBe(''); + }); + + test('stringifies an object with a null object as a child', function () { + var obj = { a: Object.create(null) }; + + obj.a.b = 'c'; + // st.equal(stringify(obj), 'a%5Bb%5D=c'); + expect(stringify(obj)).toBe('a%5Bb%5D=c'); + }); + + test('drops keys with a value of undefined', function () { + // st.equal(stringify({ a: undefined }), ''); + expect(stringify({ a: undefined })).toBe(''); + + // st.equal( + // stringify({ a: { b: undefined, c: null } }, { strictNullHandling: true }), + // 'a%5Bc%5D', + // ); + // st.equal( + // stringify({ a: { b: undefined, c: null } }, { strictNullHandling: false }), + // 'a%5Bc%5D=', + // ); + // st.equal(stringify({ a: { b: undefined, c: '' } }), 'a%5Bc%5D='); + expect(stringify({ a: { b: undefined, c: null } }, { strictNullHandling: true })).toBe('a%5Bc%5D'); + expect(stringify({ a: { b: undefined, c: null } }, { strictNullHandling: false })).toBe('a%5Bc%5D='); + expect(stringify({ a: { b: undefined, c: '' } })).toBe('a%5Bc%5D='); + }); + + test('url encodes values', function () { + // st.equal(stringify({ a: 'b c' }), 'a=b%20c'); + expect(stringify({ a: 'b c' })).toBe('a=b%20c'); + }); + + test('stringifies a date', function () { + var now = new Date(); + var str = 'a=' + encodeURIComponent(now.toISOString()); + // st.equal(stringify({ a: now }), str); + expect(stringify({ a: now })).toBe(str); + }); + + test('stringifies the weird object from qs', function () { + // st.equal( + // stringify({ 'my weird field': '~q1!2"\'w$5&7/z8)?' }), + // 'my%20weird%20field=~q1%212%22%27w%245%267%2Fz8%29%3F', + // ); + expect(stringify({ 'my weird field': '~q1!2"\'w$5&7/z8)?' })).toBe( + 'my%20weird%20field=~q1%212%22%27w%245%267%2Fz8%29%3F', + ); + }); + + // TODO: Investigate how to to intercept in vitest + // TODO(rob) + test('skips properties that are part of the object prototype', function () { + // st.intercept(Object.prototype, 'crash', { value: 'test' }); + // @ts-expect-error + Object.prototype.crash = 'test'; + + // st.equal(stringify({ a: 'b' }), 'a=b'); + // st.equal(stringify({ a: { b: 'c' } }), 'a%5Bb%5D=c'); + expect(stringify({ a: 'b' })).toBe('a=b'); + expect(stringify({ a: { b: 'c' } })).toBe('a%5Bb%5D=c'); + }); + + test('stringifies boolean values', function () { + // st.equal(stringify({ a: true }), 'a=true'); + // st.equal(stringify({ a: { b: true } }), 'a%5Bb%5D=true'); + // st.equal(stringify({ b: false }), 'b=false'); + // st.equal(stringify({ b: { c: false } }), 'b%5Bc%5D=false'); + expect(stringify({ a: true })).toBe('a=true'); + expect(stringify({ a: { b: true } })).toBe('a%5Bb%5D=true'); + expect(stringify({ b: false })).toBe('b=false'); + expect(stringify({ b: { c: false } })).toBe('b%5Bc%5D=false'); + }); + + test('stringifies buffer values', function () { + // st.equal(stringify({ a: Buffer.from('test') }), 'a=test'); + // st.equal(stringify({ a: { b: Buffer.from('test') } }), 'a%5Bb%5D=test'); + }); + + test('stringifies an object using an alternative delimiter', function () { + // st.equal(stringify({ a: 'b', c: 'd' }, { delimiter: ';' }), 'a=b;c=d'); + expect(stringify({ a: 'b', c: 'd' }, { delimiter: ';' })).toBe('a=b;c=d'); + }); + + // We dont target environments which dont even have Buffer + // test('does not blow up when Buffer global is missing', function () { + // var restore = mockProperty(global, 'Buffer', { delete: true }); + + // var result = stringify({ a: 'b', c: 'd' }); + + // restore(); + + // st.equal(result, 'a=b&c=d'); + // st.end(); + // }); + + test('does not crash when parsing circular references', function () { + var a: any = {}; + a.b = a; + + // st['throws']( + // function () { + // stringify({ 'foo[bar]': 'baz', 'foo[baz]': a }); + // }, + // /RangeError: Cyclic object value/, + // 'cyclic values throw', + // ); + expect(() => { + stringify({ 'foo[bar]': 'baz', 'foo[baz]': a }); + }).toThrow('Cyclic object value'); + + var circular: any = { + a: 'value', + }; + circular.a = circular; + // st['throws']( + // function () { + // stringify(circular); + // }, + // /RangeError: Cyclic object value/, + // 'cyclic values throw', + // ); + expect(() => { + stringify(circular); + }).toThrow('Cyclic object value'); + + var arr = ['a']; + // st.doesNotThrow(function () { + // stringify({ x: arr, y: arr }); + // }, 'non-cyclic values do not throw'); + expect(() => { + stringify({ x: arr, y: arr }); + }).not.toThrow(); + }); + + test('non-circular duplicated references can still work', function () { + var hourOfDay = { + function: 'hour_of_day', + }; + + var p1 = { + function: 'gte', + arguments: [hourOfDay, 0], + }; + var p2 = { + function: 'lte', + arguments: [hourOfDay, 23], + }; + + // st.equal( + // stringify( + // { filters: { $and: [p1, p2] } }, + // { encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'filters[$and][0][function]=gte&filters[$and][0][arguments][0][function]=hour_of_day&filters[$and][0][arguments][1]=0&filters[$and][1][function]=lte&filters[$and][1][arguments][0][function]=hour_of_day&filters[$and][1][arguments][1]=23', + // ); + // st.equal( + // stringify( + // { filters: { $and: [p1, p2] } }, + // { encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'filters[$and][][function]=gte&filters[$and][][arguments][][function]=hour_of_day&filters[$and][][arguments][]=0&filters[$and][][function]=lte&filters[$and][][arguments][][function]=hour_of_day&filters[$and][][arguments][]=23', + // ); + // st.equal( + // stringify( + // { filters: { $and: [p1, p2] } }, + // { encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'filters[$and][function]=gte&filters[$and][arguments][function]=hour_of_day&filters[$and][arguments]=0&filters[$and][function]=lte&filters[$and][arguments][function]=hour_of_day&filters[$and][arguments]=23', + // ); + expect( + stringify({ filters: { $and: [p1, p2] } }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + ).toBe( + 'filters[$and][0][function]=gte&filters[$and][0][arguments][0][function]=hour_of_day&filters[$and][0][arguments][1]=0&filters[$and][1][function]=lte&filters[$and][1][arguments][0][function]=hour_of_day&filters[$and][1][arguments][1]=23', + ); + expect( + stringify({ filters: { $and: [p1, p2] } }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe( + 'filters[$and][][function]=gte&filters[$and][][arguments][][function]=hour_of_day&filters[$and][][arguments][]=0&filters[$and][][function]=lte&filters[$and][][arguments][][function]=hour_of_day&filters[$and][][arguments][]=23', + ); + expect( + stringify({ filters: { $and: [p1, p2] } }, { encodeValuesOnly: true, arrayFormat: 'repeat' }), + ).toBe( + 'filters[$and][function]=gte&filters[$and][arguments][function]=hour_of_day&filters[$and][arguments]=0&filters[$and][function]=lte&filters[$and][arguments][function]=hour_of_day&filters[$and][arguments]=23', + ); + }); + + test('selects properties when filter=array', function () { + // st.equal(stringify({ a: 'b' }, { filter: ['a'] }), 'a=b'); + // st.equal(stringify({ a: 1 }, { filter: [] }), ''); + expect(stringify({ a: 'b' }, { filter: ['a'] })).toBe('a=b'); + expect(stringify({ a: 1 }, { filter: [] })).toBe(''); + + // st.equal( + // stringify( + // { a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, + // { filter: ['a', 'b', 0, 2], arrayFormat: 'indices' }, + // ), + // 'a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B2%5D=3', + // 'indices => indices', + // ); + // st.equal( + // stringify( + // { a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, + // { filter: ['a', 'b', 0, 2], arrayFormat: 'brackets' }, + // ), + // 'a%5Bb%5D%5B%5D=1&a%5Bb%5D%5B%5D=3', + // 'brackets => brackets', + // ); + // st.equal( + // stringify({ a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, { filter: ['a', 'b', 0, 2] }), + // 'a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B2%5D=3', + // 'default => indices', + // ); + expect(stringify({ a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, { filter: ['a', 'b', 0, 2] })).toBe( + 'a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B2%5D=3', + ); + expect( + stringify( + { a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, + { filter: ['a', 'b', 0, 2], arrayFormat: 'indices' }, + ), + ).toBe('a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B2%5D=3'); + expect( + stringify( + { a: { b: [1, 2, 3, 4], c: 'd' }, c: 'f' }, + { filter: ['a', 'b', 0, 2], arrayFormat: 'brackets' }, + ), + ).toBe('a%5Bb%5D%5B%5D=1&a%5Bb%5D%5B%5D=3'); + }); + + test('supports custom representations when filter=function', function () { + var calls = 0; + var obj = { a: 'b', c: 'd', e: { f: new Date(1257894000000) } }; + var filterFunc: StringifyOptions['filter'] = function (prefix, value) { + calls += 1; + if (calls === 1) { + // st.equal(prefix, '', 'prefix is empty'); + // st.equal(value, obj); + expect(prefix).toBe(''); + expect(value).toBe(obj); + } else if (prefix === 'c') { + return void 0; + } else if (value instanceof Date) { + // st.equal(prefix, 'e[f]'); + expect(prefix).toBe('e[f]'); + return value.getTime(); + } + return value; + }; + + // st.equal(stringify(obj, { filter: filterFunc }), 'a=b&e%5Bf%5D=1257894000000'); + // st.equal(calls, 5); + expect(stringify(obj, { filter: filterFunc })).toBe('a=b&e%5Bf%5D=1257894000000'); + expect(calls).toBe(5); + }); + + test('can disable uri encoding', function () { + // st.equal(stringify({ a: 'b' }, { encode: false }), 'a=b'); + // st.equal(stringify({ a: { b: 'c' } }, { encode: false }), 'a[b]=c'); + // st.equal( + // stringify({ a: 'b', c: null }, { strictNullHandling: true, encode: false }), + // 'a=b&c', + // ); + expect(stringify({ a: 'b' }, { encode: false })).toBe('a=b'); + expect(stringify({ a: { b: 'c' } }, { encode: false })).toBe('a[b]=c'); + expect(stringify({ a: 'b', c: null }, { strictNullHandling: true, encode: false })).toBe('a=b&c'); + }); + + test('can sort the keys', function () { + // @ts-expect-error + var sort: NonNullable = function (a: string, b: string) { + return a.localeCompare(b); + }; + // st.equal(stringify({ a: 'c', z: 'y', b: 'f' }, { sort: sort }), 'a=c&b=f&z=y'); + // st.equal( + // stringify({ a: 'c', z: { j: 'a', i: 'b' }, b: 'f' }, { sort: sort }), + // 'a=c&b=f&z%5Bi%5D=b&z%5Bj%5D=a', + // ); + expect(stringify({ a: 'c', z: 'y', b: 'f' }, { sort: sort })).toBe('a=c&b=f&z=y'); + expect(stringify({ a: 'c', z: { j: 'a', i: 'b' }, b: 'f' }, { sort: sort })).toBe( + 'a=c&b=f&z%5Bi%5D=b&z%5Bj%5D=a', + ); + }); + + test('can sort the keys at depth 3 or more too', function () { + // @ts-expect-error + var sort: NonNullable = function (a: string, b: string) { + return a.localeCompare(b); + }; + // st.equal( + // stringify( + // { a: 'a', z: { zj: { zjb: 'zjb', zja: 'zja' }, zi: { zib: 'zib', zia: 'zia' } }, b: 'b' }, + // { sort: sort, encode: false }, + // ), + // 'a=a&b=b&z[zi][zia]=zia&z[zi][zib]=zib&z[zj][zja]=zja&z[zj][zjb]=zjb', + // ); + // st.equal( + // stringify( + // { a: 'a', z: { zj: { zjb: 'zjb', zja: 'zja' }, zi: { zib: 'zib', zia: 'zia' } }, b: 'b' }, + // { sort: null, encode: false }, + // ), + // 'a=a&z[zj][zjb]=zjb&z[zj][zja]=zja&z[zi][zib]=zib&z[zi][zia]=zia&b=b', + // ); + expect( + stringify( + { a: 'a', z: { zj: { zjb: 'zjb', zja: 'zja' }, zi: { zib: 'zib', zia: 'zia' } }, b: 'b' }, + { sort: sort, encode: false }, + ), + ).toBe('a=a&b=b&z[zi][zia]=zia&z[zi][zib]=zib&z[zj][zja]=zja&z[zj][zjb]=zjb'); + expect( + stringify( + { a: 'a', z: { zj: { zjb: 'zjb', zja: 'zja' }, zi: { zib: 'zib', zia: 'zia' } }, b: 'b' }, + { sort: null, encode: false }, + ), + ).toBe('a=a&z[zj][zjb]=zjb&z[zj][zja]=zja&z[zi][zib]=zib&z[zi][zia]=zia&b=b'); + }); + + test('can stringify with custom encoding', function () { + // st.equal( + // stringify( + // { 県: '大阪府', '': '' }, + // { + // encoder: function (str) { + // if (str.length === 0) { + // return ''; + // } + // var buf = iconv.encode(str, 'shiftjis'); + // var result = []; + // for (var i = 0; i < buf.length; ++i) { + // result.push(buf.readUInt8(i).toString(16)); + // } + // return '%' + result.join('%'); + // }, + // }, + // ), + // '%8c%a7=%91%e5%8d%e3%95%7b&=', + // ); + expect( + stringify( + { 県: '大阪府', '': '' }, + { + encoder: function (str) { + if (str.length === 0) { + return ''; + } + var buf = iconv.encode(str, 'shiftjis'); + var result = []; + for (var i = 0; i < buf.length; ++i) { + result.push(buf.readUInt8(i).toString(16)); + } + return '%' + result.join('%'); + }, + }, + ), + ).toBe('%8c%a7=%91%e5%8d%e3%95%7b&='); + }); + + test('receives the default encoder as a second argument', function () { + // stringify( + // { a: 1, b: new Date(), c: true, d: [1] }, + // { + // encoder: function (str) { + // st.match(typeof str, /^(?:string|number|boolean)$/); + // return ''; + // }, + // }, + // ); + + stringify( + { a: 1, b: new Date(), c: true, d: [1] }, + { + encoder: function (str) { + // st.match(typeof str, /^(?:string|number|boolean)$/); + assert.match(typeof str, /^(?:string|number|boolean)$/); + return ''; + }, + }, + ); + }); + + test('receives the default encoder as a second argument', function () { + // stringify( + // { a: 1 }, + // { + // encoder: function (str, defaultEncoder) { + // st.equal(defaultEncoder, utils.encode); + // }, + // }, + // ); + + stringify( + { a: 1 }, + { + // @ts-ignore + encoder: function (_str, defaultEncoder) { + expect(defaultEncoder).toBe(encode); + }, + }, + ); + }); + + test('throws error with wrong encoder', function () { + // st['throws'](function () { + // stringify({}, { encoder: 'string' }); + // }, new TypeError('Encoder has to be a function.')); + // st.end(); + expect(() => { + // @ts-expect-error + stringify({}, { encoder: 'string' }); + }).toThrow(TypeError); + }); + + (typeof Buffer === 'undefined' ? test.skip : test)( + 'can use custom encoder for a buffer object', + function () { + // st.equal( + // stringify( + // { a: Buffer.from([1]) }, + // { + // encoder: function (buffer) { + // if (typeof buffer === 'string') { + // return buffer; + // } + // return String.fromCharCode(buffer.readUInt8(0) + 97); + // }, + // }, + // ), + // 'a=b', + // ); + expect( + stringify( + { a: Buffer.from([1]) }, + { + encoder: function (buffer) { + if (typeof buffer === 'string') { + return buffer; + } + return String.fromCharCode(buffer.readUInt8(0) + 97); + }, + }, + ), + ).toBe('a=b'); + + // st.equal( + // stringify( + // { a: Buffer.from('a b') }, + // { + // encoder: function (buffer) { + // return buffer; + // }, + // }, + // ), + // 'a=a b', + // ); + expect( + stringify( + { a: Buffer.from('a b') }, + { + encoder: function (buffer) { + return buffer; + }, + }, + ), + ).toBe('a=a b'); + }, + ); + + test('serializeDate option', function () { + var date = new Date(); + // st.equal( + // stringify({ a: date }), + // 'a=' + date.toISOString().replace(/:/g, '%3A'), + // 'default is toISOString', + // ); + expect(stringify({ a: date })).toBe('a=' + date.toISOString().replace(/:/g, '%3A')); + + var mutatedDate = new Date(); + mutatedDate.toISOString = function () { + throw new SyntaxError(); + }; + // st['throws'](function () { + // mutatedDate.toISOString(); + // }, SyntaxError); + expect(() => { + mutatedDate.toISOString(); + }).toThrow(SyntaxError); + // st.equal( + // stringify({ a: mutatedDate }), + // 'a=' + Date.prototype.toISOString.call(mutatedDate).replace(/:/g, '%3A'), + // 'toISOString works even when method is not locally present', + // ); + expect(stringify({ a: mutatedDate })).toBe( + 'a=' + Date.prototype.toISOString.call(mutatedDate).replace(/:/g, '%3A'), + ); + + var specificDate = new Date(6); + // st.equal( + // stringify( + // { a: specificDate }, + // { + // serializeDate: function (d) { + // return d.getTime() * 7; + // }, + // }, + // ), + // 'a=42', + // 'custom serializeDate function called', + // ); + expect( + stringify( + { a: specificDate }, + { + // @ts-ignore + serializeDate: function (d) { + return d.getTime() * 7; + }, + }, + ), + ).toBe('a=42'); + + // st.equal( + // stringify( + // { a: [date] }, + // { + // serializeDate: function (d) { + // return d.getTime(); + // }, + // arrayFormat: 'comma', + // }, + // ), + // 'a=' + date.getTime(), + // 'works with arrayFormat comma', + // ); + // st.equal( + // stringify( + // { a: [date] }, + // { + // serializeDate: function (d) { + // return d.getTime(); + // }, + // arrayFormat: 'comma', + // commaRoundTrip: true, + // }, + // ), + // 'a%5B%5D=' + date.getTime(), + // 'works with arrayFormat comma', + // ); + expect( + stringify( + { a: [date] }, + { + // @ts-expect-error + serializeDate: function (d) { + return d.getTime(); + }, + arrayFormat: 'comma', + }, + ), + ).toBe('a=' + date.getTime()); + expect( + stringify( + { a: [date] }, + { + // @ts-expect-error + serializeDate: function (d) { + return d.getTime(); + }, + arrayFormat: 'comma', + commaRoundTrip: true, + }, + ), + ).toBe('a%5B%5D=' + date.getTime()); + }); + + test('RFC 1738 serialization', function () { + // st.equal(stringify({ a: 'b c' }, { format: formats.RFC1738 }), 'a=b+c'); + // st.equal(stringify({ 'a b': 'c d' }, { format: formats.RFC1738 }), 'a+b=c+d'); + // st.equal( + // stringify({ 'a b': Buffer.from('a b') }, { format: formats.RFC1738 }), + // 'a+b=a+b', + // ); + expect(stringify({ a: 'b c' }, { format: 'RFC1738' })).toBe('a=b+c'); + expect(stringify({ 'a b': 'c d' }, { format: 'RFC1738' })).toBe('a+b=c+d'); + expect(stringify({ 'a b': Buffer.from('a b') }, { format: 'RFC1738' })).toBe('a+b=a+b'); + + // st.equal(stringify({ 'foo(ref)': 'bar' }, { format: formats.RFC1738 }), 'foo(ref)=bar'); + expect(stringify({ 'foo(ref)': 'bar' }, { format: 'RFC1738' })).toBe('foo(ref)=bar'); + }); + + test('RFC 3986 spaces serialization', function () { + // st.equal(stringify({ a: 'b c' }, { format: formats.RFC3986 }), 'a=b%20c'); + // st.equal(stringify({ 'a b': 'c d' }, { format: formats.RFC3986 }), 'a%20b=c%20d'); + // st.equal( + // stringify({ 'a b': Buffer.from('a b') }, { format: formats.RFC3986 }), + // 'a%20b=a%20b', + // ); + expect(stringify({ a: 'b c' }, { format: 'RFC3986' })).toBe('a=b%20c'); + expect(stringify({ 'a b': 'c d' }, { format: 'RFC3986' })).toBe('a%20b=c%20d'); + expect(stringify({ 'a b': Buffer.from('a b') }, { format: 'RFC3986' })).toBe('a%20b=a%20b'); + }); + + test('Backward compatibility to RFC 3986', function () { + // st.equal(stringify({ a: 'b c' }), 'a=b%20c'); + // st.equal(stringify({ 'a b': Buffer.from('a b') }), 'a%20b=a%20b'); + expect(stringify({ a: 'b c' })).toBe('a=b%20c'); + expect(stringify({ 'a b': Buffer.from('a b') })).toBe('a%20b=a%20b'); + }); + + test('Edge cases and unknown formats', function () { + ['UFO1234', false, 1234, null, {}, []].forEach(function (format) { + // st['throws'](function () { + // stringify({ a: 'b c' }, { format: format }); + // }, new TypeError('Unknown format option provided.')); + expect(() => { + // @ts-expect-error + stringify({ a: 'b c' }, { format: format }); + }).toThrow(TypeError); + }); + }); + + test('encodeValuesOnly', function () { + // st.equal( + // stringify( + // { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + // { encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h', + // 'encodeValuesOnly + indices', + // ); + // st.equal( + // stringify( + // { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + // { encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a=b&c[]=d&c[]=e%3Df&f[][]=g&f[][]=h', + // 'encodeValuesOnly + brackets', + // ); + // st.equal( + // stringify( + // { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + // { encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a=b&c=d&c=e%3Df&f=g&f=h', + // 'encodeValuesOnly + repeat', + // ); + expect( + stringify( + { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + { encodeValuesOnly: true, arrayFormat: 'indices' }, + ), + ).toBe('a=b&c[0]=d&c[1]=e%3Df&f[0][0]=g&f[1][0]=h'); + expect( + stringify( + { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + { encodeValuesOnly: true, arrayFormat: 'brackets' }, + ), + ).toBe('a=b&c[]=d&c[]=e%3Df&f[][]=g&f[][]=h'); + expect( + stringify( + { a: 'b', c: ['d', 'e=f'], f: [['g'], ['h']] }, + { encodeValuesOnly: true, arrayFormat: 'repeat' }, + ), + ).toBe('a=b&c=d&c=e%3Df&f=g&f=h'); + + // st.equal( + // stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'indices' }), + // 'a=b&c%5B0%5D=d&c%5B1%5D=e&f%5B0%5D%5B0%5D=g&f%5B1%5D%5B0%5D=h', + // 'no encodeValuesOnly + indices', + // ); + // st.equal( + // stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'brackets' }), + // 'a=b&c%5B%5D=d&c%5B%5D=e&f%5B%5D%5B%5D=g&f%5B%5D%5B%5D=h', + // 'no encodeValuesOnly + brackets', + // ); + // st.equal( + // stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'repeat' }), + // 'a=b&c=d&c=e&f=g&f=h', + // 'no encodeValuesOnly + repeat', + // ); + expect(stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'indices' })).toBe( + 'a=b&c%5B0%5D=d&c%5B1%5D=e&f%5B0%5D%5B0%5D=g&f%5B1%5D%5B0%5D=h', + ); + expect(stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'brackets' })).toBe( + 'a=b&c%5B%5D=d&c%5B%5D=e&f%5B%5D%5B%5D=g&f%5B%5D%5B%5D=h', + ); + expect(stringify({ a: 'b', c: ['d', 'e'], f: [['g'], ['h']] }, { arrayFormat: 'repeat' })).toBe( + 'a=b&c=d&c=e&f=g&f=h', + ); + }); + + test('encodeValuesOnly - strictNullHandling', function () { + // st.equal( + // stringify({ a: { b: null } }, { encodeValuesOnly: true, strictNullHandling: true }), + // 'a[b]', + // ); + expect(stringify({ a: { b: null } }, { encodeValuesOnly: true, strictNullHandling: true })).toBe('a[b]'); + }); + + test('throws if an invalid charset is specified', function () { + // st['throws'](function () { + // stringify({ a: 'b' }, { charset: 'foobar' }); + // }, new TypeError('The charset option must be either utf-8, iso-8859-1, or undefined')); + expect(() => { + // @ts-expect-error + stringify({ a: 'b' }, { charset: 'foobar' }); + }).toThrow(TypeError); + }); + + test('respects a charset of iso-8859-1', function () { + // st.equal(stringify({ æ: 'æ' }, { charset: 'iso-8859-1' }), '%E6=%E6'); + expect(stringify({ æ: 'æ' }, { charset: 'iso-8859-1' })).toBe('%E6=%E6'); + }); + + test('encodes unrepresentable chars as numeric entities in iso-8859-1 mode', function () { + // st.equal(stringify({ a: '☺' }, { charset: 'iso-8859-1' }), 'a=%26%239786%3B'); + expect(stringify({ a: '☺' }, { charset: 'iso-8859-1' })).toBe('a=%26%239786%3B'); + }); + + test('respects an explicit charset of utf-8 (the default)', function () { + // st.equal(stringify({ a: 'æ' }, { charset: 'utf-8' }), 'a=%C3%A6'); + expect(stringify({ a: 'æ' }, { charset: 'utf-8' })).toBe('a=%C3%A6'); + }); + + test('`charsetSentinel` option', function () { + // st.equal( + // stringify({ a: 'æ' }, { charsetSentinel: true, charset: 'utf-8' }), + // 'utf8=%E2%9C%93&a=%C3%A6', + // 'adds the right sentinel when instructed to and the charset is utf-8', + // ); + expect(stringify({ a: 'æ' }, { charsetSentinel: true, charset: 'utf-8' })).toBe( + 'utf8=%E2%9C%93&a=%C3%A6', + ); + + // st.equal( + // stringify({ a: 'æ' }, { charsetSentinel: true, charset: 'iso-8859-1' }), + // 'utf8=%26%2310003%3B&a=%E6', + // 'adds the right sentinel when instructed to and the charset is iso-8859-1', + // ); + expect(stringify({ a: 'æ' }, { charsetSentinel: true, charset: 'iso-8859-1' })).toBe( + 'utf8=%26%2310003%3B&a=%E6', + ); + }); + + test('does not mutate the options argument', function () { + var options = {}; + stringify({}, options); + // st.deepEqual(options, {}); + expect(options).toEqual({}); + }); + + test('strictNullHandling works with custom filter', function () { + // @ts-expect-error + var filter = function (_prefix, value) { + return value; + }; + + var options = { strictNullHandling: true, filter: filter }; + // st.equal(stringify({ key: null }, options), 'key'); + expect(stringify({ key: null }, options)).toBe('key'); + }); + + test('strictNullHandling works with null serializeDate', function () { + var serializeDate = function () { + return null; + }; + var options = { strictNullHandling: true, serializeDate: serializeDate }; + var date = new Date(); + // st.equal(stringify({ key: date }, options), 'key'); + // @ts-expect-error + expect(stringify({ key: date }, options)).toBe('key'); + }); + + test('allows for encoding keys and values differently', function () { + // @ts-expect-error + var encoder = function (str, defaultEncoder, charset, type) { + if (type === 'key') { + return defaultEncoder(str, defaultEncoder, charset, type).toLowerCase(); + } + if (type === 'value') { + return defaultEncoder(str, defaultEncoder, charset, type).toUpperCase(); + } + throw 'this should never happen! type: ' + type; + }; + + // st.deepEqual(stringify({ KeY: 'vAlUe' }, { encoder: encoder }), 'key=VALUE'); + expect(stringify({ KeY: 'vAlUe' }, { encoder: encoder })).toBe('key=VALUE'); + }); + + test('objects inside arrays', function () { + var obj = { a: { b: { c: 'd', e: 'f' } } }; + var withArray = { a: { b: [{ c: 'd', e: 'f' }] } }; + + // st.equal( + // stringify(obj, { encode: false }), + // 'a[b][c]=d&a[b][e]=f', + // 'no array, no arrayFormat', + // ); + // st.equal( + // stringify(obj, { encode: false, arrayFormat: 'brackets' }), + // 'a[b][c]=d&a[b][e]=f', + // 'no array, bracket', + // ); + // st.equal( + // stringify(obj, { encode: false, arrayFormat: 'indices' }), + // 'a[b][c]=d&a[b][e]=f', + // 'no array, indices', + // ); + // st.equal( + // stringify(obj, { encode: false, arrayFormat: 'repeat' }), + // 'a[b][c]=d&a[b][e]=f', + // 'no array, repeat', + // ); + // st.equal( + // stringify(obj, { encode: false, arrayFormat: 'comma' }), + // 'a[b][c]=d&a[b][e]=f', + // 'no array, comma', + // ); + expect(stringify(obj, { encode: false })).toBe('a[b][c]=d&a[b][e]=f'); + expect(stringify(obj, { encode: false, arrayFormat: 'brackets' })).toBe('a[b][c]=d&a[b][e]=f'); + expect(stringify(obj, { encode: false, arrayFormat: 'indices' })).toBe('a[b][c]=d&a[b][e]=f'); + expect(stringify(obj, { encode: false, arrayFormat: 'repeat' })).toBe('a[b][c]=d&a[b][e]=f'); + expect(stringify(obj, { encode: false, arrayFormat: 'comma' })).toBe('a[b][c]=d&a[b][e]=f'); + + // st.equal( + // stringify(withArray, { encode: false }), + // 'a[b][0][c]=d&a[b][0][e]=f', + // 'array, no arrayFormat', + // ); + // st.equal( + // stringify(withArray, { encode: false, arrayFormat: 'brackets' }), + // 'a[b][][c]=d&a[b][][e]=f', + // 'array, bracket', + // ); + // st.equal( + // stringify(withArray, { encode: false, arrayFormat: 'indices' }), + // 'a[b][0][c]=d&a[b][0][e]=f', + // 'array, indices', + // ); + // st.equal( + // stringify(withArray, { encode: false, arrayFormat: 'repeat' }), + // 'a[b][c]=d&a[b][e]=f', + // 'array, repeat', + // ); + // st.equal( + // stringify(withArray, { encode: false, arrayFormat: 'comma' }), + // '???', + // 'array, comma', + // { skip: 'TODO: figure out what this should do' }, + // ); + expect(stringify(withArray, { encode: false })).toBe('a[b][0][c]=d&a[b][0][e]=f'); + expect(stringify(withArray, { encode: false, arrayFormat: 'brackets' })).toBe('a[b][][c]=d&a[b][][e]=f'); + expect(stringify(withArray, { encode: false, arrayFormat: 'indices' })).toBe('a[b][0][c]=d&a[b][0][e]=f'); + expect(stringify(withArray, { encode: false, arrayFormat: 'repeat' })).toBe('a[b][c]=d&a[b][e]=f'); + // !TODo: Figure out what this should do + // expect(stringify(withArray, { encode: false, arrayFormat: 'comma' })).toBe( + // 'a[b][c]=d&a[b][e]=f', + // ); + }); + + test('stringifies sparse arrays', function () { + // st.equal( + // stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + // 'a[1]=2&a[4]=1', + // ); + // st.equal( + // stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + // 'a[]=2&a[]=1', + // ); + // st.equal( + // stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'repeat' }), + // 'a=2&a=1', + // ); + expect(stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'indices' })).toBe( + 'a[1]=2&a[4]=1', + ); + expect(stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'brackets' })).toBe( + 'a[]=2&a[]=1', + ); + expect(stringify({ a: [, '2', , , '1'] }, { encodeValuesOnly: true, arrayFormat: 'repeat' })).toBe( + 'a=2&a=1', + ); + + // st.equal( + // stringify( + // { a: [, { b: [, , { c: '1' }] }] }, + // { encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a[1][b][2][c]=1', + // ); + // st.equal( + // stringify( + // { a: [, { b: [, , { c: '1' }] }] }, + // { encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a[][b][][c]=1', + // ); + // st.equal( + // stringify( + // { a: [, { b: [, , { c: '1' }] }] }, + // { encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a[b][c]=1', + // ); + expect( + stringify({ a: [, { b: [, , { c: '1' }] }] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + ).toBe('a[1][b][2][c]=1'); + expect( + stringify({ a: [, { b: [, , { c: '1' }] }] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe('a[][b][][c]=1'); + expect( + stringify({ a: [, { b: [, , { c: '1' }] }] }, { encodeValuesOnly: true, arrayFormat: 'repeat' }), + ).toBe('a[b][c]=1'); + + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: '1' }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a[1][2][3][c]=1', + // ); + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: '1' }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a[][][][c]=1', + // ); + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: '1' }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a[c]=1', + // ); + expect( + stringify({ a: [, [, , [, , , { c: '1' }]]] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + ).toBe('a[1][2][3][c]=1'); + expect( + stringify({ a: [, [, , [, , , { c: '1' }]]] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe('a[][][][c]=1'); + expect( + stringify({ a: [, [, , [, , , { c: '1' }]]] }, { encodeValuesOnly: true, arrayFormat: 'repeat' }), + ).toBe('a[c]=1'); + + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: [, '1'] }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'indices' }, + // ), + // 'a[1][2][3][c][1]=1', + // ); + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: [, '1'] }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'brackets' }, + // ), + // 'a[][][][c][]=1', + // ); + // st.equal( + // stringify( + // { a: [, [, , [, , , { c: [, '1'] }]]] }, + // { encodeValuesOnly: true, arrayFormat: 'repeat' }, + // ), + // 'a[c]=1', + // ); + expect( + stringify({ a: [, [, , [, , , { c: [, '1'] }]]] }, { encodeValuesOnly: true, arrayFormat: 'indices' }), + ).toBe('a[1][2][3][c][1]=1'); + expect( + stringify({ a: [, [, , [, , , { c: [, '1'] }]]] }, { encodeValuesOnly: true, arrayFormat: 'brackets' }), + ).toBe('a[][][][c][]=1'); + expect( + stringify({ a: [, [, , [, , , { c: [, '1'] }]]] }, { encodeValuesOnly: true, arrayFormat: 'repeat' }), + ).toBe('a[c]=1'); + }); + + test('encodes a very long string', function () { + var chars = []; + var expected = []; + for (var i = 0; i < 5e3; i++) { + chars.push(' ' + i); + + expected.push('%20' + i); + } + + var obj = { + foo: chars.join(''), + }; + + // st.equal( + // stringify(obj, { arrayFormat: 'bracket', charset: 'utf-8' }), + // 'foo=' + expected.join(''), + // ); + // @ts-expect-error + expect(stringify(obj, { arrayFormat: 'bracket', charset: 'utf-8' })).toBe('foo=' + expected.join('')); + }); +}); + +describe('stringifies empty keys', function () { + empty_test_cases.forEach(function (testCase) { + test('stringifies an object with empty string key with ' + testCase.input, function () { + // st.deepEqual( + // stringify(testCase.withEmptyKeys, { encode: false, arrayFormat: 'indices' }), + // testCase.stringifyOutput.indices, + // 'test case: ' + testCase.input + ', indices', + // ); + // st.deepEqual( + // stringify(testCase.withEmptyKeys, { encode: false, arrayFormat: 'brackets' }), + // testCase.stringifyOutput.brackets, + // 'test case: ' + testCase.input + ', brackets', + // ); + // st.deepEqual( + // stringify(testCase.withEmptyKeys, { encode: false, arrayFormat: 'repeat' }), + // testCase.stringifyOutput.repeat, + // 'test case: ' + testCase.input + ', repeat', + // ); + expect(stringify(testCase.with_empty_keys, { encode: false, arrayFormat: 'indices' })).toBe( + testCase.stringify_output.indices, + ); + expect(stringify(testCase.with_empty_keys, { encode: false, arrayFormat: 'brackets' })).toBe( + testCase.stringify_output.brackets, + ); + expect(stringify(testCase.with_empty_keys, { encode: false, arrayFormat: 'repeat' })).toBe( + testCase.stringify_output.repeat, + ); + }); + }); + + test('edge case with object/arrays', function () { + // st.deepEqual(stringify({ '': { '': [2, 3] } }, { encode: false }), '[][0]=2&[][1]=3'); + // st.deepEqual( + // stringify({ '': { '': [2, 3], a: 2 } }, { encode: false }), + // '[][0]=2&[][1]=3&[a]=2', + // ); + // st.deepEqual( + // stringify({ '': { '': [2, 3] } }, { encode: false, arrayFormat: 'indices' }), + // '[][0]=2&[][1]=3', + // ); + // st.deepEqual( + // stringify({ '': { '': [2, 3], a: 2 } }, { encode: false, arrayFormat: 'indices' }), + // '[][0]=2&[][1]=3&[a]=2', + // ); + expect(stringify({ '': { '': [2, 3] } }, { encode: false })).toBe('[][0]=2&[][1]=3'); + expect(stringify({ '': { '': [2, 3], a: 2 } }, { encode: false })).toBe('[][0]=2&[][1]=3&[a]=2'); + expect(stringify({ '': { '': [2, 3] } }, { encode: false, arrayFormat: 'indices' })).toBe( + '[][0]=2&[][1]=3', + ); + expect(stringify({ '': { '': [2, 3], a: 2 } }, { encode: false, arrayFormat: 'indices' })).toBe( + '[][0]=2&[][1]=3&[a]=2', + ); + }); +}); diff --git a/tests/qs/utils.test.ts b/tests/qs/utils.test.ts new file mode 100644 index 0000000..82982d2 --- /dev/null +++ b/tests/qs/utils.test.ts @@ -0,0 +1,169 @@ +import { combine, merge, is_buffer, assign_single_source } from 'langsmith/internal/qs/utils'; + +describe('merge()', function () { + // t.deepEqual(merge(null, true), [null, true], 'merges true into null'); + expect(merge(null, true)).toEqual([null, true]); + + // t.deepEqual(merge(null, [42]), [null, 42], 'merges null into an array'); + expect(merge(null, [42])).toEqual([null, 42]); + + // t.deepEqual( + // merge({ a: 'b' }, { a: 'c' }), + // { a: ['b', 'c'] }, + // 'merges two objects with the same key', + // ); + expect(merge({ a: 'b' }, { a: 'c' })).toEqual({ a: ['b', 'c'] }); + + var oneMerged = merge({ foo: 'bar' }, { foo: { first: '123' } }); + // t.deepEqual( + // oneMerged, + // { foo: ['bar', { first: '123' }] }, + // 'merges a standalone and an object into an array', + // ); + expect(oneMerged).toEqual({ foo: ['bar', { first: '123' }] }); + + var twoMerged = merge({ foo: ['bar', { first: '123' }] }, { foo: { second: '456' } }); + // t.deepEqual( + // twoMerged, + // { foo: { 0: 'bar', 1: { first: '123' }, second: '456' } }, + // 'merges a standalone and two objects into an array', + // ); + expect(twoMerged).toEqual({ foo: { 0: 'bar', 1: { first: '123' }, second: '456' } }); + + var sandwiched = merge({ foo: ['bar', { first: '123', second: '456' }] }, { foo: 'baz' }); + // t.deepEqual( + // sandwiched, + // { foo: ['bar', { first: '123', second: '456' }, 'baz'] }, + // 'merges an object sandwiched by two standalones into an array', + // ); + expect(sandwiched).toEqual({ foo: ['bar', { first: '123', second: '456' }, 'baz'] }); + + var nestedArrays = merge({ foo: ['baz'] }, { foo: ['bar', 'xyzzy'] }); + // t.deepEqual(nestedArrays, { foo: ['baz', 'bar', 'xyzzy'] }); + expect(nestedArrays).toEqual({ foo: ['baz', 'bar', 'xyzzy'] }); + + var noOptionsNonObjectSource = merge({ foo: 'baz' }, 'bar'); + // t.deepEqual(noOptionsNonObjectSource, { foo: 'baz', bar: true }); + expect(noOptionsNonObjectSource).toEqual({ foo: 'baz', bar: true }); + + (typeof Object.defineProperty !== 'function' ? test.skip : test)( + 'avoids invoking array setters unnecessarily', + function () { + var setCount = 0; + var getCount = 0; + var observed: any[] = []; + Object.defineProperty(observed, 0, { + get: function () { + getCount += 1; + return { bar: 'baz' }; + }, + set: function () { + setCount += 1; + }, + }); + merge(observed, [null]); + // st.equal(setCount, 0); + // st.equal(getCount, 1); + expect(setCount).toEqual(0); + expect(getCount).toEqual(1); + observed[0] = observed[0]; + // st.equal(setCount, 1); + // st.equal(getCount, 2); + expect(setCount).toEqual(1); + expect(getCount).toEqual(2); + }, + ); +}); + +test('assign()', function () { + var target = { a: 1, b: 2 }; + var source = { b: 3, c: 4 }; + var result = assign_single_source(target, source); + + expect(result).toEqual(target); + expect(target).toEqual({ a: 1, b: 3, c: 4 }); + expect(source).toEqual({ b: 3, c: 4 }); +}); + +describe('combine()', function () { + test('both arrays', function () { + var a = [1]; + var b = [2]; + var combined = combine(a, b); + + // st.deepEqual(a, [1], 'a is not mutated'); + // st.deepEqual(b, [2], 'b is not mutated'); + // st.notEqual(a, combined, 'a !== combined'); + // st.notEqual(b, combined, 'b !== combined'); + // st.deepEqual(combined, [1, 2], 'combined is a + b'); + expect(a).toEqual([1]); + expect(b).toEqual([2]); + expect(combined).toEqual([1, 2]); + expect(a).not.toEqual(combined); + expect(b).not.toEqual(combined); + }); + + test('one array, one non-array', function () { + var aN = 1; + var a = [aN]; + var bN = 2; + var b = [bN]; + + var combinedAnB = combine(aN, b); + // st.deepEqual(b, [bN], 'b is not mutated'); + // st.notEqual(aN, combinedAnB, 'aN + b !== aN'); + // st.notEqual(a, combinedAnB, 'aN + b !== a'); + // st.notEqual(bN, combinedAnB, 'aN + b !== bN'); + // st.notEqual(b, combinedAnB, 'aN + b !== b'); + // st.deepEqual([1, 2], combinedAnB, 'first argument is array-wrapped when not an array'); + expect(b).toEqual([bN]); + expect(combinedAnB).not.toEqual(aN); + expect(combinedAnB).not.toEqual(a); + expect(combinedAnB).not.toEqual(bN); + expect(combinedAnB).not.toEqual(b); + expect(combinedAnB).toEqual([1, 2]); + + var combinedABn = combine(a, bN); + // st.deepEqual(a, [aN], 'a is not mutated'); + // st.notEqual(aN, combinedABn, 'a + bN !== aN'); + // st.notEqual(a, combinedABn, 'a + bN !== a'); + // st.notEqual(bN, combinedABn, 'a + bN !== bN'); + // st.notEqual(b, combinedABn, 'a + bN !== b'); + // st.deepEqual([1, 2], combinedABn, 'second argument is array-wrapped when not an array'); + expect(a).toEqual([aN]); + expect(combinedABn).not.toEqual(aN); + expect(combinedABn).not.toEqual(a); + expect(combinedABn).not.toEqual(bN); + expect(combinedABn).not.toEqual(b); + expect(combinedABn).toEqual([1, 2]); + }); + + test('neither is an array', function () { + var combined = combine(1, 2); + // st.notEqual(1, combined, '1 + 2 !== 1'); + // st.notEqual(2, combined, '1 + 2 !== 2'); + // st.deepEqual([1, 2], combined, 'both arguments are array-wrapped when not an array'); + expect(combined).not.toEqual(1); + expect(combined).not.toEqual(2); + expect(combined).toEqual([1, 2]); + }); +}); + +test('is_buffer()', function () { + for (const x of [null, undefined, true, false, '', 'abc', 42, 0, NaN, {}, [], function () {}, /a/g]) { + // t.equal(is_buffer(x), false, inspect(x) + ' is not a buffer'); + expect(is_buffer(x)).toEqual(false); + } + + var fakeBuffer = { constructor: Buffer }; + // t.equal(is_buffer(fakeBuffer), false, 'fake buffer is not a buffer'); + expect(is_buffer(fakeBuffer)).toEqual(false); + + var saferBuffer = Buffer.from('abc'); + // t.equal(is_buffer(saferBuffer), true, 'SaferBuffer instance is a buffer'); + expect(is_buffer(saferBuffer)).toEqual(true); + + var buffer = Buffer.from('abc'); + // t.equal(is_buffer(buffer), true, 'real Buffer instance is a buffer'); + expect(is_buffer(buffer)).toEqual(true); +}); diff --git a/tests/runs-aliases.test.ts b/tests/runs-aliases.test.ts new file mode 100644 index 0000000..4795ffa --- /dev/null +++ b/tests/runs-aliases.test.ts @@ -0,0 +1,20 @@ +import { Langsmith } from '../src/client'; + +describe('run aliases', () => { + test.each(['retrieve', 'query'] as const)('%s calls the V2 endpoint', async (method) => { + const fetch = jest.fn( + async () => + new Response(JSON.stringify(method === 'retrieve' ? { id: 'run' } : { runs: [{ id: 'run' }] }), { + headers: { 'content-type': 'application/json' }, + }), + ); + const client = new Langsmith({ apiKey: 'test', baseURL: 'https://example.test', fetch }); + const result = + method === 'retrieve' ? client.runs.retrieve('run', { project_id: 'project' }) : client.runs.query({}); + const { response } = await result.withResponse(); + expect(response.status).toBe(200); + const [url, options] = (fetch.mock.calls as unknown as [string, RequestInit][])[0]!; + expect(new URL(url).pathname).toBe(method === 'retrieve' ? '/api/v2/runs/run' : '/api/v2/runs/query'); + expect(options.method).toBe(method === 'retrieve' ? 'GET' : 'POST'); + }); +}); diff --git a/tests/stringifyQuery.test.ts b/tests/stringifyQuery.test.ts new file mode 100644 index 0000000..cd4eca1 --- /dev/null +++ b/tests/stringifyQuery.test.ts @@ -0,0 +1,21 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import { stringifyQuery } from 'langsmith/internal/utils/query'; + +describe(stringifyQuery, () => { + for (const [input, expected] of [ + [{ a: '1', b: 2, c: true }, 'a=1&b=2&c=true'], + [{ a: null, b: false, c: undefined }, 'a=&b=false'], + [{ 'a/b': 1.28341 }, `${encodeURIComponent('a/b')}=1.28341`], + [ + { 'a/b': 'c/d', 'e=f': 'g&h' }, + `${encodeURIComponent('a/b')}=${encodeURIComponent('c/d')}&${encodeURIComponent( + 'e=f', + )}=${encodeURIComponent('g&h')}`, + ], + ] as const) { + it(`${JSON.stringify(input)} -> ${expected}`, () => { + expect(stringifyQuery(input)).toEqual(expected); + }); + } +}); diff --git a/tests/uploads.test.ts b/tests/uploads.test.ts new file mode 100644 index 0000000..1734f05 --- /dev/null +++ b/tests/uploads.test.ts @@ -0,0 +1,106 @@ +import fs from 'fs'; +import type { ResponseLike } from 'langsmith/internal/to-file'; +import { toFile } from 'langsmith/core/uploads'; + +class MyClass { + name: string = 'foo'; +} + +function mockResponse({ url, content }: { url: string; content?: Blob }): ResponseLike { + return { + url, + blob: async () => content || new Blob([]), + }; +} + +describe('toFile', () => { + it('throws a helpful error for mismatched types', async () => { + await expect( + // @ts-expect-error intentionally mismatched type + toFile({ foo: 'string' }), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unexpected data type: object; constructor: Object; props: ["foo"]"`, + ); + + await expect( + // @ts-expect-error intentionally mismatched type + toFile(new MyClass()), + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unexpected data type: object; constructor: MyClass; props: ["name"]"`, + ); + }); + + it('disallows string at the type-level', async () => { + // @ts-expect-error we intentionally do not type support for `string` + // to help people avoid passing a file path + const file = await toFile('contents'); + expect(file.text()).resolves.toEqual('contents'); + }); + + it('extracts a file name from a Response', async () => { + const response = mockResponse({ url: 'https://example.com/my/audio.mp3' }); + const file = await toFile(response); + expect(file.name).toEqual('audio.mp3'); + }); + + it('extracts a file name from a File', async () => { + const input = new File(['foo'], 'input.jsonl'); + const file = await toFile(input); + expect(file.name).toEqual('input.jsonl'); + }); + + it('extracts a file name from a ReadStream', async () => { + const input = fs.createReadStream('tests/uploads.test.ts'); + const file = await toFile(input); + expect(file.name).toEqual('uploads.test.ts'); + }); + + it('does not copy File objects', async () => { + const input = new File(['foo'], 'input.jsonl', { type: 'jsonl' }); + const file = await toFile(input); + expect(file).toBe(input); + expect(file.name).toEqual('input.jsonl'); + expect(file.type).toBe('jsonl'); + }); + + it('is assignable to File and Blob', async () => { + const input = new File(['foo'], 'input.jsonl', { type: 'jsonl' }); + const result = await toFile(input); + const file: File = result; + const blob: Blob = result; + void file, blob; + }); +}); + +describe('missing File error message', () => { + let prevGlobalFile: unknown; + let prevNodeFile: unknown; + beforeEach(() => { + // The file shim captures the global File object when it's first imported. + // Reset modules before each test so we can test the error thrown when it's undefined. + jest.resetModules(); + const buffer = require('node:buffer'); + // @ts-ignore + prevGlobalFile = globalThis.File; + prevNodeFile = buffer.File; + // @ts-ignore + globalThis.File = undefined; + buffer.File = undefined; + }); + afterEach(() => { + // Clean up + // @ts-ignore + globalThis.File = prevGlobalFile; + require('node:buffer').File = prevNodeFile; + jest.resetModules(); + }); + + test('is thrown', async () => { + const uploads = await import('langsmith/core/uploads'); + await expect( + uploads.toFile(mockResponse({ url: 'https://example.com/my/audio.mp3' })), + ).rejects.toMatchInlineSnapshot( + `[Error: \`File\` is not defined as a global, which is required for file uploads.]`, + ); + }); +}); diff --git a/tsc-multi.json b/tsc-multi.json new file mode 100644 index 0000000..384ddac --- /dev/null +++ b/tsc-multi.json @@ -0,0 +1,15 @@ +{ + "targets": [ + { + "extname": ".js", + "module": "commonjs", + "shareHelpers": "internal/tslib.js" + }, + { + "extname": ".mjs", + "module": "esnext", + "shareHelpers": "internal/tslib.mjs" + } + ], + "projects": ["tsconfig.build.json"] +} diff --git a/tsc-multi.vitest.json b/tsc-multi.vitest.json new file mode 100644 index 0000000..e502eae --- /dev/null +++ b/tsc-multi.vitest.json @@ -0,0 +1,9 @@ +{ + "targets": [ + { + "extname": ".mjs", + "module": "esnext" + } + ], + "projects": ["tsconfig.build.vitest.json"] +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..6d8567e --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "./tsconfig.json", + "include": ["dist/src"], + "exclude": ["**/*.mts"], + "compilerOptions": { + "rootDir": "./dist/src", + "paths": { + "langsmith/*": ["./dist/src/*"], + "langsmith": ["./dist/src/index.ts"] + }, + "noEmit": false, + "declaration": true, + "declarationMap": true, + "outDir": "dist", + "pretty": true, + "sourceMap": true + } +} diff --git a/tsconfig.build.vitest.json b/tsconfig.build.vitest.json new file mode 100644 index 0000000..ffe3680 --- /dev/null +++ b/tsconfig.build.vitest.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.build.json", + "include": ["dist/src/**/*.mts"], + "exclude": [] +} diff --git a/tsconfig.deno.json b/tsconfig.deno.json new file mode 100644 index 0000000..849e070 --- /dev/null +++ b/tsconfig.deno.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "include": ["dist-deno"], + "exclude": [], + "compilerOptions": { + "rootDir": "./dist-deno", + "lib": ["es2020", "DOM"], + "noEmit": true, + "declaration": true, + "declarationMap": true, + "outDir": "dist-deno", + "pretty": true, + "sourceMap": true + } +} diff --git a/tsconfig.dist-src.json b/tsconfig.dist-src.json new file mode 100644 index 0000000..c550e29 --- /dev/null +++ b/tsconfig.dist-src.json @@ -0,0 +1,11 @@ +{ + // this config is included in the published src directory to prevent TS errors + // from appearing when users go to source, and VSCode opens the source .ts file + // via declaration maps + "include": ["index.ts"], + "compilerOptions": { + "target": "ES2015", + "lib": ["DOM", "DOM.Iterable", "ES2018"], + "moduleResolution": "node" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..d654374 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*.mts", "src/lib/tests"], + "exclude": ["**/*.test.bun.ts"], + "compilerOptions": { + "module": "esnext" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9aa1ddf --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,36 @@ +{ + "include": ["src", "tests", "examples"], + "exclude": ["**/*.mts", "src/lib/tests"], + "compilerOptions": { + "target": "es2020", + "lib": ["es2021", "es2022.object", "es2022.array", "dom", "dom.iterable"], + "module": "commonjs", + "moduleResolution": "node", + "esModuleInterop": true, + "paths": { + "langsmith/*": ["./src/*"], + "langsmith": ["./src/index.ts"] + }, + "noEmit": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": false, + "noImplicitThis": true, + "noImplicitReturns": true, + "alwaysStrict": true, + // ponytail: the hand-written SDK (src/lib) was never held to these extra Stainless checks; re-enable once src/lib is clean. + "exactOptionalPropertyTypes": false, + "noUncheckedIndexedAccess": false, + "noImplicitOverride": false, + "noPropertyAccessFromIndexSignature": false, + "isolatedModules": false, + "skipLibCheck": true, + "useDefineForClassFields": true, + "stripInternal": true + } +} diff --git a/typedoc.json b/typedoc.json new file mode 100644 index 0000000..3186689 --- /dev/null +++ b/typedoc.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://typedoc.org/schema.json", + "out": "_build/api_refs", + "sort": ["kind", "visibility", "instance-first", "required-first", "alphabetical"], + "plugin": ["typedoc-plugin-expand-object-like-types", "./scripts/typedoc-plugin.cjs"], + "tsconfig": "./tsconfig.json", + "excludePrivate": true, + "excludeInternal": true, + "excludeExternals": false, + "excludeNotDocumented": false, + "includeVersion": true, + "sourceLinkTemplate": "https://github.com/langchain-ai/langsmith-javascript/blob/{gitRevision}/{path}#L{line}", + "logLevel": "Error", + "name": "LangSmith", + "skipErrorChecking": false, + "exclude": ["dist"], + "hostedBaseUrl": "https://docs.smith.langchain.com/reference/js/", + "entryPoints": [ + "src/lib/client.ts", + "src/lib/run_trees.ts", + "src/lib/traceable.ts", + "src/lib/evaluation/index.ts", + "src/lib/schemas.ts", + "src/lib/langchain.ts", + "src/lib/jest/index.ts", + "src/lib/jest/reporter.ts", + "src/lib/vitest/index.ts", + "src/lib/vitest/reporter.ts", + "src/lib/wrappers/index.ts", + "src/lib/anonymizer/index.ts", + "src/lib/wrappers/anthropic.ts", + "src/lib/wrappers/openai.ts", + "src/lib/wrappers/gemini.ts", + "src/lib/wrappers/openai_agents.ts", + "src/lib/singletons/traceable.ts", + "src/lib/utils/jestlike/index.ts", + "src/lib/experimental/otel/setup.ts", + "src/lib/experimental/otel/exporter.ts", + "src/lib/experimental/otel/processor.ts", + "src/lib/experimental/vercel/index.ts", + "src/lib/experimental/anthropic/index.ts", + "src/lib/sandbox/index.ts" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d856429 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.vitesttest.?(c|m)[jt]s"], + setupFiles: ["dotenv/config"], + hookTimeout: 30000, + }, +});