From 28484bbb31b00ba8d8c7aeb3b05a2f4ad3ca9183 Mon Sep 17 00:00:00 2001 From: qihai Date: Wed, 25 Jun 2025 15:29:29 +0800 Subject: [PATCH 01/13] feat(cozeloop-langchain): callback --- common/config/rush/pnpm-lock.yaml | 447 ++++++++++++++- cspell.json | 7 +- packages/cozeloop-ai/src/prompt/hub.ts | 2 +- packages/cozeloop-ai/src/prompt/types.ts | 2 +- packages/cozeloop-ai/src/tracer/index.ts | 4 + packages/cozeloop-ai/src/utils/common.ts | 8 +- .../__tests__/__mock__/custom-model.ts | 41 ++ .../__tests__/__mock__/custom-retriever.ts | 26 + .../__tests__/__mock__/index.ts | 2 + .../__tests__/__mock__/react-agent.ts | 44 ++ .../__tests__/batching-queue.test.ts | 64 +++ .../__tests__/callback.test.ts | 77 +++ .../__tests__/utils.test.ts | 94 ++++ packages/cozeloop-langchain/eslint.config.js | 10 + packages/cozeloop-langchain/package.json | 78 +++ .../src/callbacks/batching-queue.ts | 92 ++++ .../src/callbacks/callback-handler.ts | 519 ++++++++++++++++++ .../src/callbacks/constants.ts | 40 ++ .../cozeloop-langchain/src/callbacks/index.ts | 6 + .../src/callbacks/processor.ts | 79 +++ .../src/callbacks/schema.ts | 48 ++ .../src/callbacks/tree-log-processor.ts | 66 +++ .../cozeloop-langchain/src/callbacks/utils.ts | 102 ++++ packages/cozeloop-langchain/src/global.d.ts | 14 + packages/cozeloop-langchain/src/index.ts | 2 + .../cozeloop-langchain/tsconfig.build.json | 10 + packages/cozeloop-langchain/tsconfig.json | 24 + .../cozeloop-langchain/tsconfig.typings.json | 15 + packages/cozeloop-langchain/tsup.config.ts | 31 ++ packages/cozeloop-langchain/vitest.config.ts | 23 + rush.json | 4 + 31 files changed, 1970 insertions(+), 11 deletions(-) create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/custom-retriever.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/index.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/react-agent.ts create mode 100644 packages/cozeloop-langchain/__tests__/batching-queue.test.ts create mode 100644 packages/cozeloop-langchain/__tests__/callback.test.ts create mode 100644 packages/cozeloop-langchain/__tests__/utils.test.ts create mode 100644 packages/cozeloop-langchain/eslint.config.js create mode 100644 packages/cozeloop-langchain/package.json create mode 100644 packages/cozeloop-langchain/src/callbacks/batching-queue.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/callback-handler.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/constants.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/index.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/processor.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/schema.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/utils.ts create mode 100644 packages/cozeloop-langchain/src/global.d.ts create mode 100644 packages/cozeloop-langchain/src/index.ts create mode 100644 packages/cozeloop-langchain/tsconfig.build.json create mode 100644 packages/cozeloop-langchain/tsconfig.json create mode 100644 packages/cozeloop-langchain/tsconfig.typings.json create mode 100644 packages/cozeloop-langchain/tsup.config.ts create mode 100644 packages/cozeloop-langchain/vitest.config.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 77d0cb0..539a9e1 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -226,7 +226,7 @@ importers: version: 1.9.0 openai: specifier: ^4.92.0 - version: 4.92.1 + version: 4.92.1(zod@3.25.67) devDependencies: '@loop-infra/eslint-config': specifier: workspace:* @@ -336,7 +336,80 @@ importers: version: 3.0.2 tsup: specifier: ^8.0.1 - version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2) + version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.0) + typescript: + specifier: ^5.5.3 + version: 5.8.2 + vitest: + specifier: ~2.1.4 + version: 2.1.9(@types/node@20.17.22)(happy-dom@15.11.7)(msw@2.7.3(@types/node@20.17.22)(typescript@5.8.2)) + + ../../packages/cozeloop-langchain: + dependencies: + '@opentelemetry/api': + specifier: ~1.9.0 + version: 1.9.0 + '@opentelemetry/core': + specifier: ~1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': + specifier: ~0.57.2 + version: 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': + specifier: ~1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': + specifier: ~0.57.2 + version: 0.57.2(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': + specifier: ~1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': + specifier: ~1.30.1 + version: 1.30.1(@opentelemetry/api@1.9.0) + nanoid: + specifier: ^3.x + version: 3.3.8 + remeda: + specifier: ^2.21.2 + version: 2.21.2 + zod: + specifier: ^3.25.67 + version: 3.25.67 + devDependencies: + '@langchain/core': + specifier: ^0.3.61 + version: 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/langgraph': + specifier: ^0.3.1 + version: 0.3.1(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67)) + '@langchain/openai': + specifier: ^0.5.12 + version: 0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + '@loop-infra/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@loop-infra/ts-config': + specifier: workspace:* + version: link:../../config/ts-config + '@loop-infra/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + '@types/node': + specifier: ^20 + version: 20.17.22 + '@vitest/coverage-v8': + specifier: ~2.1.4 + version: 2.1.9(vitest@2.1.9(@types/node@20.17.22)(happy-dom@15.11.7)(msw@2.7.3(@types/node@20.17.22)(typescript@5.8.2))) + langchain: + specifier: ^0.3.28 + version: 0.3.29(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(zod@3.25.67)) + msw: + specifier: ^2.7.3 + version: 2.7.3(@types/node@20.17.22)(typescript@5.8.2) + tsup: + specifier: ^8.0.1 + version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.0) typescript: specifier: ^5.5.3 version: 5.8.2 @@ -1065,6 +1138,9 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@cfworker/json-schema@4.1.1': + resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1489,6 +1565,49 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@langchain/core@0.3.61': + resolution: {integrity: sha512-4O7fw5SXNSE+uBnathLQrhm3t+7dZGagt/5kt37A+pXw0AkudxEBvveg73sSnpBd9SIz3/Vc7F4k8rCKXGbEDA==} + 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.83': + resolution: {integrity: sha512-xYW1kX5GuGbuvAtNFMjknHcDCOz0s5Dwu7VP4kfz7F9NVQ+ftAZP2m79xEMTUY1WoXZdOxqBqONpizqMcO1zAw==} + peerDependencies: + '@langchain/core': '>=0.2.31 <0.4.0' + react: ^18 || ^19 + peerDependenciesMeta: + '@langchain/core': + optional: true + react: + optional: true + + '@langchain/langgraph@0.3.1': + resolution: {integrity: sha512-BFa6DmKthPG1znkUhTBBqdza4dojs7QGg6o9V9aptIkWWdBF/UhRbjPvCx6ldkmU2oJRNWZ1BlkiP6RflxxPwA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': '>=0.2.36 <0.3.0 || >=0.3.40 < 0.4.0' + zod-to-json-schema: ^3.x + peerDependenciesMeta: + zod-to-json-schema: + optional: true + + '@langchain/openai@0.5.15': + resolution: {integrity: sha512-ANadEHyAj5sufQpz+SOPpKbyoMcTLhnh8/d+afbSPUqWsIMPpEFX3HoSY3nrBPG6l4NQQNG5P5oHb4SdC8+YIg==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': '>=0.3.58 <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' + '@microsoft/tsdoc-config@0.16.2': resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} @@ -1927,6 +2046,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + '@typescript-eslint/eslint-plugin@5.38.1': resolution: {integrity: sha512-ky7EFzPhqz3XlhS7vPOoMDaQnQMn+9o5ICR9CPr/6bw8HrFkzhMSxuA3gRfiJVvs7geYrSeawGJjZoZQKCOglQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2188,6 +2310,10 @@ packages: 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.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} @@ -2310,6 +2436,9 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -2358,6 +2487,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + caniuse-lite@1.0.30001701: resolution: {integrity: sha512-faRs/AW3jA9nTwmJBSO1PQ6L/EOgsB5HMQQq4iCu5zhPgVVgO/pZRHlmatwijZKetFw8/Pr4q6dEN8sJuq8qTw==} @@ -2421,6 +2554,9 @@ packages: resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} engines: {node: ^14.18.0 || >=16.10.0} + console-table-printer@2.14.2: + resolution: {integrity: sha512-TyXKHIzSBFAuxRpgB4MA3RhFVzghJGpG8/eHmpWGm/2ezdswpbdVkxN7xTvDM3snIDKc8UrUs2NiR4LFjv/F1w==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -2466,6 +2602,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -2853,6 +2993,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + expect-type@1.2.0: resolution: {integrity: sha512-80F22aiJ3GLyVnS/B3HzgR6RelZVumzj9jkL0Rhz4h0xYbNW9PjlQz5h3J/SShErbXBc295vseR4/MIbVmUbeA==} engines: {node: '>=12.0.0'} @@ -3270,6 +3413,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tiktoken@1.0.20: + resolution: {integrity: sha512-Xlaqhhs8VfCd6Sh7a1cFkZHQbYTLCwVJJWiHVxBYzLPxW0XsoxBy1hitmjkdIjD3Aon5BXLHFwU5O8WUx6HH+A==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3312,6 +3458,10 @@ packages: engines: {node: '>=6'} hasBin: true + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -3329,6 +3479,72 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + langchain@0.3.29: + resolution: {integrity: sha512-L389pKlApVJPqu4hp58qY6NZAobI+MFPoBjSfjT1z3mcxtB68wLFGhaH4DVsTVg21NYO+0wTEoz24BWrxu9YGw==} + 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.3.33: + resolution: {integrity: sha512-imNIaBL6+ElE5eMzNHYwFxo6W/6rHlqcaUjCYoIeGdCYWlARxE3CTGKul5DJnaUgGP2CTLFeNXyvRx5HWC/4KQ==} + peerDependencies: + openai: '*' + peerDependenciesMeta: + openai: + optional: true + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3485,6 +3701,10 @@ packages: typescript: optional: true + mustache@4.2.0: + resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} + hasBin: true + mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} engines: {node: ^18.17.0 || >=20.5.0} @@ -3573,6 +3793,21 @@ packages: zod: optional: true + openai@5.7.0: + resolution: {integrity: sha512-zXWawZl6J/P5Wz57/nKzVT3kJQZvogfuyuNVCdEp4/XU2UNrjL7SsuNpWAyLZbo6HVymwmnfno9toVzBhelygA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + 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'} @@ -3584,6 +3819,10 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + 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'} @@ -3600,6 +3839,18 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} 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'} @@ -3849,6 +4100,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -3947,6 +4202,9 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-wcswidth@1.0.1: + resolution: {integrity: sha512-xMO/8eNREtaROt7tJvWJqHBDTMFN4eiQ5I4JRMuilwfnFcV5W9u7RUkueNkdw0jPqGMX36iCywelS5yilTuOxg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -4268,6 +4526,14 @@ packages: url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + + uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} @@ -4420,6 +4686,11 @@ packages: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} engines: {node: '>= 6'} + yaml@2.8.0: + resolution: {integrity: sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -4436,6 +4707,14 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zod-to-json-schema@3.24.5: + resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} + peerDependencies: + zod: ^3.24.1 + + zod@3.25.67: + resolution: {integrity: sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==} + snapshots: '@ampproject/remapping@2.3.0': @@ -5353,6 +5632,8 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 + '@cfworker/json-schema@4.1.1': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -5626,6 +5907,63 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))': + dependencies: + '@cfworker/json-schema': 4.1.1 + ansi-styles: 5.2.0 + camelcase: 6.3.0 + decamelize: 1.2.0 + js-tiktoken: 1.0.20 + langsmith: 0.3.33(openai@5.7.0(zod@3.25.67)) + mustache: 4.2.0 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 10.0.0 + zod: 3.25.67 + zod-to-json-schema: 3.24.5(zod@3.25.67) + transitivePeerDependencies: + - openai + + '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + dependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + uuid: 10.0.0 + + '@langchain/langgraph-sdk@0.0.83(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + dependencies: + '@types/json-schema': 7.0.15 + p-queue: 6.6.2 + p-retry: 4.6.2 + uuid: 9.0.1 + optionalDependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + + '@langchain/langgraph@0.3.1(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67))': + dependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + '@langchain/langgraph-sdk': 0.0.83(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + uuid: 10.0.0 + zod: 3.25.67 + optionalDependencies: + zod-to-json-schema: 3.24.5(zod@3.25.67) + transitivePeerDependencies: + - react + + '@langchain/openai@0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + dependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + js-tiktoken: 1.0.20 + openai: 5.7.0(zod@3.25.67) + zod: 3.25.67 + transitivePeerDependencies: + - ws + + '@langchain/textsplitters@0.1.0(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + dependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + js-tiktoken: 1.0.20 + '@microsoft/tsdoc-config@0.16.2': dependencies: '@microsoft/tsdoc': 0.14.2 @@ -6122,6 +6460,8 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/uuid@10.0.0': {} + '@typescript-eslint/eslint-plugin@5.38.1(@typescript-eslint/parser@5.38.1(eslint@9.14.0)(typescript@5.8.2))(eslint@9.14.0)(typescript@5.8.2)': dependencies: '@typescript-eslint/parser': 5.38.1(eslint@9.14.0)(typescript@5.8.2) @@ -6486,6 +6826,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.1: {} any-promise@1.3.0: {} @@ -6664,6 +7006,8 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -6714,6 +7058,8 @@ snapshots: callsites@3.1.0: {} + camelcase@6.3.0: {} + caniuse-lite@1.0.30001701: {} chai@5.2.0: @@ -6769,6 +7115,10 @@ snapshots: consola@3.4.0: {} + console-table-printer@2.14.2: + dependencies: + simple-wcswidth: 1.0.1 + convert-source-map@2.0.0: {} cookie@0.7.2: {} @@ -6819,6 +7169,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-eql@5.0.2: {} deep-is@0.1.4: {} @@ -7396,6 +7748,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@4.0.7: {} + expect-type@1.2.0: {} fast-deep-equal@3.1.3: {} @@ -7822,6 +8176,10 @@ snapshots: joycon@3.1.1: {} + js-tiktoken@1.0.20: + dependencies: + base64-js: 1.5.1 + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -7848,6 +8206,8 @@ snapshots: json5@2.2.3: {} + jsonpointer@5.0.1: {} + jsonwebtoken@9.0.2: dependencies: jws: 3.2.2 @@ -7883,6 +8243,38 @@ snapshots: dependencies: json-buffer: 3.0.1 + langchain@0.3.29(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(zod@3.25.67)): + dependencies: + '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/openai': 0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + js-tiktoken: 1.0.20 + js-yaml: 4.1.0 + jsonpointer: 5.0.1 + langsmith: 0.3.33(openai@5.7.0(zod@3.25.67)) + openapi-types: 12.1.3 + p-retry: 4.6.2 + uuid: 10.0.0 + yaml: 2.8.0 + zod: 3.25.67 + optionalDependencies: + axios: 1.9.0 + transitivePeerDependencies: + - openai + - ws + + langsmith@0.3.33(openai@5.7.0(zod@3.25.67)): + dependencies: + '@types/uuid': 10.0.0 + chalk: 4.1.2 + console-table-printer: 2.14.2 + p-queue: 6.6.2 + p-retry: 4.6.2 + semver: 7.7.1 + uuid: 10.0.0 + optionalDependencies: + openai: 5.7.0(zod@3.25.67) + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -8024,6 +8416,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + mustache@4.2.0: {} + mute-stream@2.0.0: {} mz@2.7.0: @@ -8104,7 +8498,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@4.92.1: + openai@4.92.1(zod@3.25.67): dependencies: '@types/node': 18.19.86 '@types/node-fetch': 2.6.12 @@ -8113,9 +8507,17 @@ snapshots: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.7.0 + optionalDependencies: + zod: 3.25.67 transitivePeerDependencies: - encoding + openai@5.7.0(zod@3.25.67): + optionalDependencies: + zod: 3.25.67 + + openapi-types@12.1.3: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8133,6 +8535,8 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-finally@1.0.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -8149,6 +8553,20 @@ snapshots: dependencies: p-limit: 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: {} @@ -8197,12 +8615,13 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.3): + postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.19.3)(yaml@2.8.0): dependencies: lilconfig: 3.1.3 optionalDependencies: postcss: 8.5.3 tsx: 4.19.3 + yaml: 2.8.0 postcss@8.5.3: dependencies: @@ -8380,6 +8799,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + reusify@1.1.0: {} rimraf@3.0.2: @@ -8510,6 +8931,8 @@ snapshots: signal-exit@4.1.0: {} + simple-wcswidth@1.0.1: {} + slash@3.0.0: {} sort-object-keys@1.1.3: {} @@ -8729,7 +9152,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2): + tsup@8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.0): dependencies: bundle-require: 5.1.0(esbuild@0.25.0) cac: 6.7.14 @@ -8739,7 +9162,7 @@ snapshots: esbuild: 0.25.0 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.3) + postcss-load-config: 6.0.1(postcss@8.5.3)(tsx@4.19.3)(yaml@2.8.0) resolve-from: 5.0.0 rollup: 4.34.9 source-map: 0.8.0-beta.0 @@ -8856,6 +9279,10 @@ snapshots: querystringify: 2.2.0 requires-port: 1.0.0 + uuid@10.0.0: {} + + uuid@9.0.1: {} + validate-npm-package-license@3.0.4: dependencies: spdx-correct: 3.2.0 @@ -9033,6 +9460,8 @@ snapshots: yaml@1.10.2: {} + yaml@2.8.0: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -9048,3 +9477,9 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + + zod-to-json-schema@3.24.5(zod@3.25.67): + dependencies: + zod: 3.25.67 + + zod@3.25.67: {} diff --git a/cspell.json b/cspell.json index 432746c..258039e 100644 --- a/cspell.json +++ b/cspell.json @@ -8,10 +8,15 @@ "autoinstallers", "Bytedance", "cozeloop", - "Langsmith", + "doubao", + "kwargs", + "langchain", + "langgraph", + "langsmith", "loggable", "packagejson", "preinstall", + "remeda", "traceparent", "tracestate", "vikingdb", diff --git a/packages/cozeloop-ai/src/prompt/hub.ts b/packages/cozeloop-ai/src/prompt/hub.ts index 92c86f0..1638fdb 100644 --- a/packages/cozeloop-ai/src/prompt/hub.ts +++ b/packages/cozeloop-ai/src/prompt/hub.ts @@ -22,7 +22,7 @@ export class PromptHub { private readonly _cache: PromptCache; private _api: PromptApi; - /** Promopt cache instance */ + /** Prompt cache instance */ get cache() { return this._cache; } diff --git a/packages/cozeloop-ai/src/prompt/types.ts b/packages/cozeloop-ai/src/prompt/types.ts index 88daa15..ebd1580 100644 --- a/packages/cozeloop-ai/src/prompt/types.ts +++ b/packages/cozeloop-ai/src/prompt/types.ts @@ -22,7 +22,7 @@ export interface PromptHubOptions { apiClient?: ApiClient | ApiClientOptions; /** Prompt cache options, see {@link PromptCacheOptions } */ cacheOptions?: PromptCacheOptions; - /** Enavle trace report for `getPrompt` and `formatPrompt` */ + /** Enable trace report for `getPrompt` and `formatPrompt` */ traceable?: boolean; /** A logger function to print debug message */ logger?: SimpleLogger; diff --git a/packages/cozeloop-ai/src/tracer/index.ts b/packages/cozeloop-ai/src/tracer/index.ts index 26d8b89..b734dbe 100644 --- a/packages/cozeloop-ai/src/tracer/index.ts +++ b/packages/cozeloop-ai/src/tracer/index.ts @@ -2,8 +2,11 @@ // SPDX-License-Identifier: MIT import { traceable } from './wrapper'; import { setError, setInput, setOutput, setTag, setTags } from './utils/tags'; +import { getTracer } from './utils'; import { tracerInitModule } from './initialize'; +export { tracerInitModule } from './initialize'; + export { type LoopTraceWrapperOptions, type LoopTraceInitializeOptions, @@ -65,4 +68,5 @@ export const cozeLoopTracer = { * Shutdown tracer */ shutdown: tracerInitModule.shutdown, + getTracer: () => getTracer(), }; diff --git a/packages/cozeloop-ai/src/utils/common.ts b/packages/cozeloop-ai/src/utils/common.ts index 0532bf4..e0de664 100644 --- a/packages/cozeloop-ai/src/utils/common.ts +++ b/packages/cozeloop-ai/src/utils/common.ts @@ -55,7 +55,11 @@ export function stringifyVal(val: unknown) { case 'symbol': return val.toString(); case 'object': - return val === null ? '' : JSON.stringify(val); + return val === null + ? '' + : val instanceof Date + ? val.toISOString() + : JSON.stringify(val); case 'undefined': return ''; case 'function': @@ -65,7 +69,7 @@ export function stringifyVal(val: unknown) { } } -/** parse value to numver safely, NaN is treated as undefined */ +/** parse value to number safely, NaN is treated as undefined */ export function safeNumber(value: unknown) { if (typeof value === 'number' && !isNaN(value)) { return value; diff --git a/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts b/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts new file mode 100644 index 0000000..6366bdb --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts @@ -0,0 +1,41 @@ +import { setTimeout } from 'node:timers/promises'; + +import { GenerationChunk } from '@langchain/core/outputs'; +import { LLM } from '@langchain/core/language_models/llms'; +import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; + +export class CustomLLM extends LLM { + readonly delay = 10; + readonly chunkSize = 2; + + _llmType() { + return 'custom'; + } + + async _call( + prompt: string, + options: this['ParsedCallOptions'], + runManager: CallbackManagerForLLMRun, + ): Promise { + // Pass `runManager?.getChild()` when invoking internal runnables to enable tracing + // await subRunnable.invoke(params, runManager?.getChild()); + return await setTimeout(this.delay, `[MOCK] ${prompt}`); + } + + async *_streamResponseChunks( + prompt: string, + options: this['ParsedCallOptions'], + runManager?: CallbackManagerForLLMRun, + ): AsyncGenerator { + // Pass `runManager?.getChild()` when invoking internal runnables to enable tracing + // await subRunnable.invoke(params, runManager?.getChild()); + const fullText = `[MOCK] ${prompt}`; + for (const letter of fullText.split('')) { + yield new GenerationChunk({ + text: letter, + }); + // Trigger the appropriate callback + await runManager?.handleLLMNewToken(letter); + } + } +} diff --git a/packages/cozeloop-langchain/__tests__/__mock__/custom-retriever.ts b/packages/cozeloop-langchain/__tests__/__mock__/custom-retriever.ts new file mode 100644 index 0000000..5af061b --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/custom-retriever.ts @@ -0,0 +1,26 @@ +import { BaseRetriever } from '@langchain/core/retrievers'; +import { Document } from '@langchain/core/documents'; +import type { CallbackManagerForRetrieverRun } from '@langchain/core/callbacks/manager'; + +export class CustomRetriever extends BaseRetriever { + lc_namespace = ['langchain', 'retrievers']; + + async _getRelevantDocuments( + query: string, + runManager?: CallbackManagerForRetrieverRun, + ): Promise { + // Pass `runManager?.getChild()` when invoking internal runnables to enable tracing + // const additionalDocs = await someOtherRunnable.invoke(params, runManager?.getChild()); + return await [ + // ...additionalDocs, + new Document({ + pageContent: `Some document pertaining to ${query}`, + metadata: {}, + }), + new Document({ + pageContent: `Some other document pertaining to ${query}`, + metadata: {}, + }), + ]; + } +} diff --git a/packages/cozeloop-langchain/__tests__/__mock__/index.ts b/packages/cozeloop-langchain/__tests__/__mock__/index.ts new file mode 100644 index 0000000..7d6b0c1 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/index.ts @@ -0,0 +1,2 @@ +export { CustomLLM } from './custom-model'; +export { CustomRetriever } from './custom-retriever'; diff --git a/packages/cozeloop-langchain/__tests__/__mock__/react-agent.ts b/packages/cozeloop-langchain/__tests__/__mock__/react-agent.ts new file mode 100644 index 0000000..b602748 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/react-agent.ts @@ -0,0 +1,44 @@ +import { setTimeout } from 'node:timers/promises'; + +import { AgentExecutor, createOpenAIToolsAgent } from 'langchain/agents'; +import { AzureChatOpenAI } from '@langchain/openai'; +import { DynamicTool } from '@langchain/core/tools'; +import { + ChatPromptTemplate, + MessagesPlaceholder, +} from '@langchain/core/prompts'; +import { SystemMessage } from '@langchain/core/messages'; + +const translationTool = new DynamicTool({ + name: 'translator', + description: '将输入的文本翻译成英文', + func: async (input: string) => + await setTimeout(100, `fake translate ${input}`), +}); + +const tools = [translationTool]; + +const llm = new AzureChatOpenAI({ + temperature: 0, + modelName: 'gpt-4o-2024-05-13', + azureOpenAIApiInstanceName: 'azure-ins', + azureOpenAIApiDeploymentName: 'azure-dep', + azureOpenAIEndpoint: process.env.GPT_OPEN_API_BASE_URL, + azureOpenAIApiVersion: '2024-03-01-preview', + azureOpenAIApiKey: process.env.GPT_OPEN_API_KEY, + maxTokens: 1000, +}); + +const agent = await createOpenAIToolsAgent({ + llm, + tools, + prompt: ChatPromptTemplate.fromMessages([ + new SystemMessage('translate user query. {agent_scratchpad}'), + new MessagesPlaceholder('agent_scratchpad'), + ]), +}); + +export const reactAgentExecutor = new AgentExecutor({ + agent, + tools, +}); diff --git a/packages/cozeloop-langchain/__tests__/batching-queue.test.ts b/packages/cozeloop-langchain/__tests__/batching-queue.test.ts new file mode 100644 index 0000000..ef6ad75 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/batching-queue.test.ts @@ -0,0 +1,64 @@ +import { BatchingQueue } from '../src/callbacks/batching-queue'; + +describe('BatchingQueue', () => { + it('🧪 should dequeue after delay', async () => { + vi.useFakeTimers(); + + const onDequeue = vi.fn(); + const queue = new BatchingQueue(3, 100, onDequeue); + + queue.enqueue(1); + queue.enqueue(2); + + expect(onDequeue).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(200); + expect(onDequeue).toHaveBeenCalledWith([1, 2]); + + vi.useRealTimers(); + }); + + it('🧪 should dequeue immediately when batch size is reached', () => { + const onDequeue = vi.fn(); + const queue = new BatchingQueue(2, 1000, onDequeue); + + queue.enqueue(1); + queue.enqueue(2); + + expect(onDequeue).toHaveBeenCalledWith([1, 2]); + }); + + it('🧪 should dequeue remaining items on flush', () => { + const onDequeue = vi.fn(); + const queue = new BatchingQueue(3, 1000, onDequeue); + + queue.enqueue(1); + queue.enqueue(2); + queue.flush(); + + expect(onDequeue).toHaveBeenCalledWith([1, 2]); + }); + + it('🧪 should dequeue remaining items on destroy', () => { + const onDequeue = vi.fn(); + const queue = new BatchingQueue(3, 1000, onDequeue); + + queue.enqueue(1); + queue.enqueue(2); + queue.destroy(); + + expect(onDequeue).toHaveBeenCalledWith([1, 2]); + }); + + it('🧪 should clear timer on destroy', () => { + const onDequeue = vi.fn(); + const queue = new BatchingQueue(3, 1000, onDequeue); + + queue.enqueue(1); + queue.enqueue(2); + queue.destroy(); + + // eslint-disable-next-line @typescript-eslint/dot-notation -- skip + expect(queue['_timer']).toBeNull(); + expect(onDequeue).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/callback.test.ts b/packages/cozeloop-langchain/__tests__/callback.test.ts new file mode 100644 index 0000000..07c5ef1 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/callback.test.ts @@ -0,0 +1,77 @@ +import { ChatPromptTemplate } from '@langchain/core/prompts'; + +import { reactAgentExecutor } from './__mock__/react-agent'; +import { CustomLLM } from './__mock__'; +import { CozeloopCallbackHandler } from '../src'; + +describe('Callback Test', () => { + it('🧪 invoke model', async () => { + const prompt = ChatPromptTemplate.fromTemplate('What is 1 + {number}?'); + const model = new CustomLLM({}); + const chain = prompt.pipe(model); + const callback = new CozeloopCallbackHandler({ + spanProcessor: { + headers: { + 'x-tt-env': 'boe_otel_ingest_trace', + }, + traceEndpoint: + 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', + }, + // ignoreAgent: true, + // ignoreLLM: true, + // ignorePrompt: true, + // ignoreChain: true, + // ignoreCustomEvent: true, + // ignoreRetriever: true, + // raiseError: true, + }); + + const resp = await chain.invoke( + { number: 1 }, + { + runName: 'SuperChain', + callbacks: [callback], + }, + ); + console.info(resp); + // to ensure report success + await callback.shutdown(); + }); + + it('🧪 stream model', async () => { + const callback = new CozeloopCallbackHandler({ + spanProcessor: { + traceEndpoint: + 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', + }, + }); + const model = new CustomLLM({}); + const resp = await model.stream('123', { + callbacks: [callback], + }); + for await (const chunk of resp) { + console.info('receive', chunk); + } + await callback.shutdown(); + }); + + it.only('🧪 react agent', async () => { + const callback = new CozeloopCallbackHandler({ + spanProcessor: { + traceEndpoint: + 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', + headers: { + 'x-tt-env': 'boe_otel_ingest_trace', + }, + }, + }); + const resp = await reactAgentExecutor.invoke( + { input: '翻译「苹果」到英文' }, + { callbacks: [callback] }, + ); + + console.info(resp); + + await callback.shutdown(); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/utils.test.ts b/packages/cozeloop-langchain/__tests__/utils.test.ts new file mode 100644 index 0000000..d5371f5 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/utils.test.ts @@ -0,0 +1,94 @@ +import { type Serialized } from '@langchain/core/dist/load/serializable'; + +import { + generateUUID, + stringifyVal, + guessModelProvider, + extractLLMAttributes, +} from '../src/callbacks/utils'; + +describe('generateUUID', () => { + it('🧪 should generate a valid UUID string', () => { + const uuid = generateUUID(); + const pattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + expect(pattern.test(uuid)).toBe(true); + }); +}); + +describe('stringifyVal', () => { + it('🧪 should stringify different types of values correctly', () => { + expect(stringifyVal(42)).toBe('42'); + expect(stringifyVal(3.14)).toBe('3.14'); + expect(stringifyVal(true)).toBe('true'); + expect(stringifyVal(false)).toBe('false'); + expect(stringifyVal('hello')).toBe('hello'); + expect(stringifyVal(Symbol('foo'))).toBe('Symbol(foo)'); + expect(stringifyVal(null)).toBe(''); + expect(stringifyVal(new Date('2023-04-01'))).toBe( + '2023-04-01T00:00:00.000Z', + ); + expect(stringifyVal(new Error('Something went wrong'))).toBe( + 'Something went wrong', + ); + expect(stringifyVal([1, 2, 3])).toBe('1,2,3'); + expect(stringifyVal({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + expect(stringifyVal(undefined)).toBe(''); + expect( + stringifyVal(() => { + /** noop */ + }), + ).toBe('function@'); + }); +}); + +describe('guessModelProvider', () => { + it('🧪 should guess the model provider correctly', () => { + expect(guessModelProvider('gpt-3.5-turbo')).toBe('Open AI'); + expect(guessModelProvider('claude-v1')).toBe('Anthropic'); + expect(guessModelProvider('gemini-1.0')).toBe('Gemini'); + expect(guessModelProvider('doubao-large')).toBe('Doubao'); + expect(guessModelProvider('grok-001')).toBe('Grok'); + expect(guessModelProvider('deepseek-1')).toBe('DeepSeek'); + expect(guessModelProvider('qwen-3b')).toBe('Qwen'); + expect(guessModelProvider('moonshot-xx')).toBe('MoonShot'); + expect(guessModelProvider('ernie-3.0')).toBe('Ernie'); + expect(guessModelProvider('minimax-12b')).toBe('Minimax'); + expect(guessModelProvider('unknown-model')).toBe('unknown-model'); + }); +}); + +describe('extractLLMAttributes', () => { + it('🧪 should extract LLM attributes correctly', () => { + const llm: Serialized = { + lc: 1, + type: 'constructor', + id: ['some', 'model', 'gpt-4o'], + kwargs: {}, + }; + const extraParams = { + invocation_params: { + top_p: 0.9, + top_k: 40, + temperature: 0.7, + frequency_penalty: 0.1, + presence_penalty: 0.2, + max_tokens: 1000, + }, + }; + const metadata = { + model: 'gpt-3.5-turbo', + }; + + const attributes = extractLLMAttributes(llm, extraParams, metadata); + + expect(attributes.model_name).toBe('gpt-4o'); + expect(attributes.model_provider).toBe('Open AI'); + expect(attributes.top_p).toBe(0.9); + expect(attributes.top_k).toBe(40); + expect(attributes.temperature).toBe(0.7); + expect(attributes.frequency_penalty).toBe(0.1); + expect(attributes.presence_penalty).toBe(0.2); + expect(attributes.max_tokens).toBe(1000); + }); +}); diff --git a/packages/cozeloop-langchain/eslint.config.js b/packages/cozeloop-langchain/eslint.config.js new file mode 100644 index 0000000..4efcdeb --- /dev/null +++ b/packages/cozeloop-langchain/eslint.config.js @@ -0,0 +1,10 @@ +const { defineConfig } = require('@loop-infra/eslint-config'); + +module.exports = defineConfig({ + packageRoot: __dirname, + preset: 'node', + rules: { + '@typescript-eslint/naming-convention': 'off', + 'max-params': ['warn', { max: 4 }], + }, +}); diff --git a/packages/cozeloop-langchain/package.json b/packages/cozeloop-langchain/package.json new file mode 100644 index 0000000..aa7271c --- /dev/null +++ b/packages/cozeloop-langchain/package.json @@ -0,0 +1,78 @@ +{ + "name": "@cozeloop/langchain", + "version": "0.0.1", + "description": "Integration LangChain with CozeLoop | 扣子罗盘 LangChain 集成", + "keywords": [ + "cozeloop", + "langchain", + "trace" + ], + "homepage": "https://github.com/coze-dev/cozeloop-js/tree/main/packages/cozeloop-langchain", + "bugs": { + "url": "https://github.com/coze-dev/cozeloop-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/coze-dev/cozeloop-js.git", + "directory": "packages/cozeloop-langchain" + }, + "license": "MIT", + "author": "qihai ", + "exports": { + ".": { + "types": "./dist/typings/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/typings/index.d.ts", + "files": [ + "dist", + "LICENSE", + "README.md", + "CHANGELOG.md", + "README.zh-CN.md" + ], + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "lint": "eslint ./ --cache", + "prepublishOnly": "npm run build", + "start": "vitest watch", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "vitest": "vitest" + }, + "dependencies": { + "@opentelemetry/api": "~1.9.0", + "@opentelemetry/core": "~1.30.1", + "@opentelemetry/exporter-trace-otlp-http": "~0.57.2", + "@opentelemetry/resources": "~1.30.1", + "@opentelemetry/sdk-node": "~0.57.2", + "@opentelemetry/sdk-trace-base": "~1.30.1", + "@opentelemetry/sdk-trace-node": "~1.30.1", + "remeda": "^2.21.2", + "zod": "^3.25.67" + }, + "devDependencies": { + "@langchain/core": "^0.3.61", + "@langchain/langgraph": "^0.3.1", + "@langchain/openai": "^0.5.12", + "@loop-infra/eslint-config": "workspace:*", + "@loop-infra/ts-config": "workspace:*", + "@loop-infra/vitest-config": "workspace:*", + "@types/node": "^20", + "@vitest/coverage-v8": "~2.1.4", + "langchain": "^0.3.28", + "msw": "^2.7.3", + "tsup": "^8.0.1", + "typescript": "^5.5.3", + "vitest": "~2.1.4" + }, + "peerDependencies": { + "@langchain/core": "^0.3.61", + "@langchain/langgraph": "^0.3.1" + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/batching-queue.ts b/packages/cozeloop-langchain/src/callbacks/batching-queue.ts new file mode 100644 index 0000000..5f56f2e --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/batching-queue.ts @@ -0,0 +1,92 @@ +/** + * BatchingQueue is a queue class that supports batching and delayed dequeuing. + * + * It decides when to dequeue elements based on the specified `batchSize` and `dequeueDelay`. + * The queue will dequeue elements and call the provided `onDequeue` callback function + * when the length of the queue reaches `batchSize` or `dequeueDelay` milliseconds have elapsed + * since the last element was enqueued. + * + * Additionally, it provides a `destroy` method to clear the timer and consume any remaining data. + */ +export class BatchingQueue { + private _queue: T[] = []; + private _timer: ReturnType | null = null; + private _onDequeue: ((data: T[]) => Promise | unknown) | null = null; + private _batchSize: number; + private _dequeueDelay: number; + + /** + * Create a new instance of BatchingQueue. + * @param batchSize - The maximum size for batched dequeuing. + * @param dequeueDelay - The delay in milliseconds before dequeuing. + * @param onDequeue - The callback function to be called when dequeuing, with the dequeued data as argument. + */ + constructor( + batchSize: number, + dequeueDelay: number, + onDequeue: (data: T[]) => Promise | unknown, + ) { + this._batchSize = batchSize; + this._dequeueDelay = dequeueDelay; + this._onDequeue = onDequeue; + } + + /** + * Enqueue a new element to the queue. + * @param item - The element to be enqueued. + */ + enqueue(item: T) { + this._queue.push(item); + this.startTimer(); + + if (this._queue.length >= this._batchSize) { + this._dequeue(); + } + } + + /** + * Start the delayed dequeue timer. + * If a timer is already running, it will not be restarted. + */ + private startTimer() { + if (this._timer === null) { + this._timer = setTimeout(() => { + this._dequeue(); + this._timer = null; + }, this._dequeueDelay); + } + } + + /** + * Dequeue and consumes data. + * At most `batchSize` elements will be dequeued, and the `onDequeue` callback will be called. + */ + private async _dequeue() { + if (this._queue.length > 0 && this._onDequeue !== null) { + const batch = this._queue.splice(0, this._batchSize); + await this._onDequeue(batch); + } + } + + /** + * Flush the queue, and consumes any remaining data. + */ + async flush() { + if (this._queue.length > 0 && this._onDequeue !== null) { + const batch = this._queue.splice(0); + await this._onDequeue(batch); + } + } + + /** + * Destroy the queue, clear the timer, and flush. + */ + async destroy() { + if (this._timer !== null) { + clearTimeout(this._timer); + this._timer = null; + } + + await this.flush(); + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts new file mode 100644 index 0000000..a6fff87 --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -0,0 +1,519 @@ +/* eslint-disable @typescript-eslint/no-explicit-any -- callback handler params */ +/* eslint-disable max-params -- callback handler methods */ +import { NodeSDK as OTelNodeSDK } from '@opentelemetry/sdk-node'; +import { + type Span, + SpanStatusCode, + type Tracer, + context, + trace, +} from '@opentelemetry/api'; +import { type LLMResult } from '@langchain/core/outputs'; +import { type BaseMessage } from '@langchain/core/messages'; +import { type DocumentInterface } from '@langchain/core/documents'; +import { type ChainValues } from '@langchain/core/dist/utils/types'; +import { type Serialized } from '@langchain/core/dist/load/serializable'; +import { + type AgentAction, + type AgentFinish, +} from '@langchain/core/dist/agents'; +import { + type BaseCallbackHandlerInput, + BaseCallbackHandler, + type NewTokenIndices, + type HandleLLMNewTokenCallbackFields, +} from '@langchain/core/callbacks/base'; + +import { extractLLMAttributes, generateUUID, stringifyVal } from './utils'; +import { TreeLogSpanProcessor } from './tree-log-processor'; +import { type CozeloopSpanProcessorOptions } from './schema'; +import { CozeloopSpanProcessor } from './processor'; +import { CozeloopAttr, CozeloopSpanType } from './constants'; + +export interface CozeloopCallbackHandlerInput extends BaseCallbackHandlerInput { + /** Weather to ignore prompt node like {@link ChatPromptTemplate} */ + ignorePrompt?: boolean; + spanProcessor?: Partial; +} + +// TODO: remove +export function logMethod( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor, +) { + const originalMethod = descriptor.value; + + descriptor.value = function (...args: any[]) { + console.log('🟢', propertyKey, args); + return originalMethod.apply(this, args); + }; + + return descriptor; +} + +export class CozeloopCallbackHandler + extends BaseCallbackHandler + implements CozeloopCallbackHandlerInput +{ + name = 'cozeloop-langchain-callback'; + + _awaitHandler?: boolean; + + ignorePrompt: boolean; + + private readonly _otel: OTelNodeSDK; + + private readonly _tracer: Tracer; + + private readonly _runMap = new Map(); + + private readonly _llmStartMap = new Map(); + + private readonly _promptChain = new Set(); + + private readonly _agentRunIdMap = new Map(); + + constructor(handlerInput: CozeloopCallbackHandlerInput = {}) { + const { ignorePrompt, spanProcessor = {}, ...input } = handlerInput; + super(input); + this.ignorePrompt = ignorePrompt ?? false; + this._otel = new OTelNodeSDK({ + spanProcessors: [ + new CozeloopSpanProcessor(spanProcessor), + new TreeLogSpanProcessor(), + ], + }); + this._otel.start(); + // MUST initialize tracer after otel sdk started + this._tracer = trace.getTracer(this.name, process.env.COZELOOP_VERSION); + } + + handleText( + text: string, + runId: string, + parentRunId?: string, + tags?: string[], + ): Promise | void { + this._startSpan('Text', runId, parentRunId, span => { + span.setAttribute(CozeloopAttr.INPUT, text); + }); + this._endSpan(runId, undefined); + } + + handleAgentAction( + action: AgentAction, + runId: string, + parentRunId?: string, + tags?: string[], + ): Promise | void { + let _runId = runId; + let _parentRunId = parentRunId; + // check if it use the runId exists + // see https://github.com/langchain-ai/langchainjs/issues/2488 + if (this._runMap.has(runId)) { + _runId = generateUUID(); + _parentRunId = runId; + + // set runId relations + this._agentRunIdMap.has(runId) + ? this._agentRunIdMap.get(runId)?.push(_runId) + : this._agentRunIdMap.set(runId, [_runId]); + } + const spanName = 'AgentAction'; + this._startSpan(spanName, _runId, _parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.AGENT, + [CozeloopAttr.INPUT]: stringifyVal( + // @ts-expect-error action has messageLog + stringifyVal(action.messageLog || action.log), + ), + }); + }); + } + + handleAgentEnd( + action: AgentFinish, + runId: string, + parentRunId?: string, + tags?: string[], + ): Promise | void { + this._endSpan(runId, undefined, span => { + span.setAttribute(CozeloopAttr.OUTPUT, stringifyVal(action.returnValues)); + }); + } + + handleCustomEvent( + eventName: string, + data: any, + runId: string, + tags?: string[], + metadata?: Record, + ) { + this._startSpan(eventName, runId, undefined, span => { + span.setAttribute(CozeloopAttr.INPUT, stringifyVal(data)); + }); + this._endSpan(runId, undefined); + } + + handleChatModelStart( + llm: Serialized, + messages: BaseMessage[][], + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) { + this._llmStartMap.set(runId, Date.now()); + const { model_name, model_provider, ...callOptions } = extractLLMAttributes( + llm, + extraParams, + metadata, + ); + const spanName = `${runName || model_name || 'ChatModelStart'}`; + + this._startSpan(spanName, runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.MODEL, + [CozeloopAttr.MODEL_PROVIDER]: model_provider, + [CozeloopAttr.REQUEST_MODEL]: model_name, + [CozeloopAttr.RESPONSE_MODEL]: model_name, + [CozeloopAttr.TEMPERATURE]: callOptions.temperature, + [CozeloopAttr.MAX_TOKENS]: callOptions.max_tokens, + [CozeloopAttr.TOP_P]: callOptions.top_p, + [CozeloopAttr.TOP_K]: callOptions.top_p, + [CozeloopAttr.FREQUENCY_PENALTY]: callOptions.frequency_penalty, + [CozeloopAttr.PRESENCE_PENALTY]: callOptions.presence_penalty, + }); + }); + this._endSpan(runId, undefined); + } + + handleLLMStart( + llm: Serialized, + prompts: string[], + runId: string, + parentRunId?: string, + extraParams?: Record, + tags?: string[], + metadata?: Record, + runName?: string, + ) { + this._llmStartMap.set(runId, Date.now()); + const { model_name, model_provider, ...callOptions } = extractLLMAttributes( + llm, + extraParams, + metadata, + ); + + const spanName = `${runName || model_name || 'LLMStart'}`; + this._startSpan(spanName, runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.MODEL, + [CozeloopAttr.MODEL_PROVIDER]: model_provider, + [CozeloopAttr.REQUEST_MODEL]: model_name, + [CozeloopAttr.RESPONSE_MODEL]: model_name, + [CozeloopAttr.TEMPERATURE]: callOptions.temperature, + [CozeloopAttr.MAX_TOKENS]: callOptions.max_tokens, + [CozeloopAttr.TOP_P]: callOptions.top_p, + [CozeloopAttr.TOP_K]: callOptions.top_p, + [CozeloopAttr.FREQUENCY_PENALTY]: callOptions.frequency_penalty, + [CozeloopAttr.PRESENCE_PENALTY]: callOptions.presence_penalty, + }); + }); + } + + handleLLMNewToken( + token: string, + idx: NewTokenIndices, + runId: string, + parentRunId?: string, + tags?: string[], + fields?: HandleLLMNewTokenCallbackFields, + ) { + const now = Date.now(); + const span = this._runMap.get(runId); + if (!span) { + return; + } + + const startAt = this._llmStartMap.get(runId); + if (typeof startAt !== 'undefined') { + span.setAttributes({ + [CozeloopAttr.LATENCY_FIRST_RESP]: now - startAt, + [CozeloopAttr.STREAMING]: true, + }); + this._llmStartMap.delete(runId); + } + } + + handleLLMEnd( + output: LLMResult, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) { + this._endSpan(runId, undefined, span => { + span.setAttributes({ + [CozeloopAttr.OUTPUT]: stringifyVal(output?.generations), + // TODO + // [CozeloopAttr.INPUT_TOKENS]: stringifyVal(output?.llmOutput), + // [CozeloopAttr.OUTPUT_TOKENS]: stringifyVal(output.generations), + // [CozeloopAttr.TOTAL_TOKENS]: stringifyVal(output.generations), + }); + }); + } + + handleLLMError( + err: any, + runId: string, + parentRunId?: string, + tags?: string[], + extraParams?: Record, + ) { + this._endSpan(runId, err || ''); + } + + handleChainStart( + chain: Serialized, + inputs: ChainValues, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runType?: string, + runName?: string, + ) { + // ChatPromptTemplate -> handlePromptStart + if (runType === 'prompt' || runName === 'ChatPromptTemplate') { + this.handlePromptStart( + chain, + inputs, + runId, + parentRunId, + tags, + metadata, + runType, + runName, + ); + return; + } + + const spanName = runName || 'ChainStart'; + this._startSpan(spanName, runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.CHAIN, + [CozeloopAttr.INPUT]: stringifyVal(inputs), + }); + }); + } + + handleChainEnd( + outputs: ChainValues, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) { + if (this._promptChain.has(runId)) { + this.handlePromptEnd(outputs, runId, parentRunId, tags, kwargs); + return; + } + this._endSpan(runId, undefined, span => { + span.setAttributes({ + [CozeloopAttr.OUTPUT]: stringifyVal(outputs), + }); + }); + } + + handleChainError( + err: any, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) { + if (this._promptChain.has(runId)) { + this.handlePromptError(err, runId, parentRunId, tags, kwargs); + return; + } + this._endSpan(runId, err || ''); + } + + handleToolStart( + tool: Serialized, + input: string, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runName?: string, + ) { + const spanName = `${runName ?? metadata?.toolName ?? tool.id.at(-1) ?? 'Tool'}`; + this._startSpan(spanName, runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.TOOL, + [CozeloopAttr.INPUT]: input, + }); + }); + } + + handleToolEnd( + output: any, + runId: string, + parentRunId?: string, + tags?: string[], + ) { + this._endSpan(runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.OUTPUT]: stringifyVal(output), + }); + }); + } + + handleToolError( + err: any, + runId: string, + parentRunId?: string, + tags?: string[], + ) { + this._endSpan(runId, err || ''); + } + + handleRetrieverStart( + retriever: Serialized, + query: string, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + name?: string, + ) { + console.info('handleRetrieverStart'); + } + + handleRetrieverEnd( + documents: DocumentInterface[], + runId: string, + parentRunId?: string, + tags?: string[], + ) { + console.info('handleRetrieverEnd'); + } + + handleRetrieverError( + err: any, + runId: string, + parentRunId?: string, + tags?: string[], + ) { + console.info('handleRetrieverError'); + } + + private handlePromptStart( + chain: Serialized, + inputs: ChainValues, + runId: string, + parentRunId?: string, + tags?: string[], + metadata?: Record, + runType?: string, + runName?: string, + ) { + this._promptChain.add(runId); + this._startSpan(runName || 'Prompt', runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.PROMPT, + [CozeloopAttr.INPUT]: stringifyVal(inputs), + [CozeloopAttr.PROMPT_KEY]: '', + [CozeloopAttr.PROMPT_VERSION]: '', + [CozeloopAttr.PROMPT_PROVIDER]: 'LangChain', + }); + }); + } + + private handlePromptEnd( + outputs: ChainValues, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) { + if (this.ignorePrompt) { + return; + } + this._promptChain.delete(runId); + this._endSpan(runId, undefined, span => { + span.setAttributes({ + [CozeloopAttr.OUTPUT]: stringifyVal(outputs), + }); + }); + } + + private handlePromptError( + err: any, + runId: string, + parentRunId?: string, + tags?: string[], + kwargs?: { inputs?: Record }, + ) { + if (this.ignorePrompt) { + return; + } + this._promptChain.delete(runId); + this._endSpan(runId, err || ''); + } + + private _startSpan( + name: string, + runId: string, + parentRunId: string | undefined, + cb?: (span: Span) => void, + ) { + const parentSpan = parentRunId ? this._runMap.get(parentRunId) : undefined; + const currentContext = parentSpan + ? trace.setSpan(context.active(), parentSpan) + : context.active(); + + context.with(currentContext, () => { + const span = this._tracer.startSpan(name); + // span.setAttribute(CozeloopAttr.WORKSPACE_ID, this._workspaceId || ''); + this._runMap.set(runId, span); + span.setAttribute('langchain-run-id', runId); + cb?.(span); + }); + } + + private _endSpan(runId: string, err: unknown, cb?: (span: Span) => void) { + const span = this._runMap.get(runId); + + if (!span) { + return; + } + + cb?.(span); + + if (typeof err !== 'undefined') { + const errMsg = stringifyVal(err); + span.setAttribute(CozeloopAttr.ERROR_MSG, errMsg); + span.setStatus({ code: SpanStatusCode.ERROR, message: errMsg }); + } + span.end(); + this._runMap.delete(runId); + + // end agent action related spans + if (this._agentRunIdMap.has(runId)) { + this._agentRunIdMap.get(runId)?.forEach(it => this._endSpan(it, err)); + this._agentRunIdMap.delete(runId); + } + } + + async shutdown() { + this._runMap.clear(); + this._llmStartMap.clear(); + await this._otel.shutdown(); + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/constants.ts b/packages/cozeloop-langchain/src/callbacks/constants.ts new file mode 100644 index 0000000..c4f96b4 --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/constants.ts @@ -0,0 +1,40 @@ +export enum CozeloopAttr { + WORKSPACE_ID = 'cozeloop.workspace_id', + SPAN_TYPE = 'cozeloop.span_type', + INPUT = 'cozeloop.input', + OUTPUT = 'cozeloop.output', + SESSION_ID = 'session.id', + USER_ID = 'user.id', + MSG_ID = 'messaging.message.id', + ERROR_MSG = 'error.message', + // Model + STREAMING = 'cozeloop.stream', + LATENCY_FIRST_RESP = 'cozeloop.start_time_first_resp', + MODEL_PROVIDER = 'gen_ai.system', + REQUEST_MODEL = 'gen_ai.request.model', + RESPONSE_MODEL = 'gen_ai.response.model', + TEMPERATURE = 'gen_ai.request.temperature', + MAX_TOKENS = 'gen_ai.request.max_tokens', + TOP_P = 'gen_ai.request.top_p', + TOP_K = 'gen_ai.request.top_k', + FREQUENCY_PENALTY = 'gen_ai.request.frequency_penalty', + PRESENCE_PENALTY = 'gen_ai.request.presence_penalty', + INPUT_TOKENS = 'gen_ai.usage.input_tokens', + OUTPUT_TOKENS = 'gen_ai.usage.output_tokens', + TOTAL_TOKENS = 'gen_ai.usage.total_tokens', + // STOP_SEQUENCES = 'gen_ai.request.stop_sequences', + // Prompt + PROMPT_KEY = 'cozeloop.prompt_key', + PROMPT_VERSION = 'cozeloop.prompt_version', + PROMPT_PROVIDER = 'cozeloop.prompt_provider', +} + +export enum CozeloopSpanType { + MODEL = 'model', + PROMPT = 'prompt', + RETRIEVER = 'retriever', + TOOL = 'tool', + CHAIN = 'chain', + AGENT = 'agent', + CUSTOM = 'custom', +} diff --git a/packages/cozeloop-langchain/src/callbacks/index.ts b/packages/cozeloop-langchain/src/callbacks/index.ts new file mode 100644 index 0000000..81e1f86 --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/index.ts @@ -0,0 +1,6 @@ +export { + CozeloopCallbackHandler, + type CozeloopCallbackHandlerInput, +} from './callback-handler'; + +export { CozeloopSpanProcessor } from './processor'; diff --git a/packages/cozeloop-langchain/src/callbacks/processor.ts b/packages/cozeloop-langchain/src/callbacks/processor.ts new file mode 100644 index 0000000..465aabc --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/processor.ts @@ -0,0 +1,79 @@ +import { + type ReadableSpan, + type Span, + type SpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { globalErrorHandler } from '@opentelemetry/core'; +import { type Context } from '@opentelemetry/api'; + +import { + type CozeloopSpanProcessorOptions, + CozeloopSpanProcessorOptionsSchema, +} from './schema'; +import { BatchingQueue } from './batching-queue'; + +export class CozeloopSpanProcessor implements SpanProcessor { + private _exporter: OTLPTraceExporter; + private _queue: BatchingQueue; + + private static WORKSPACE_ID_HEADER = 'cozeloop-workspace-id'; + + constructor(options: Partial) { + const { + token, + workspaceId, + headers, + traceEndpoint, + batchSize, + scheduleDelay, + } = CozeloopSpanProcessorOptionsSchema.parse(options); + this._queue = new BatchingQueue( + batchSize, + scheduleDelay, + spans => this._onExport(spans), + ); + this._exporter = new OTLPTraceExporter({ + url: traceEndpoint, + headers: { + Authorization: `Bearer ${token}`, + [CozeloopSpanProcessor.WORKSPACE_ID_HEADER]: workspaceId, + ...headers, + }, + }); + } + + private _onExport(spans: ReadableSpan[]) { + return new Promise(resolve => { + this._exporter.export(spans, result => { + result.code !== 0 && + globalErrorHandler(result.error || 'Export span error'); + resolve(result.code); + }); + }); + } + + onStart(span: Span, parentContext: Context): void { + // no op + // console.info( + // `[Start] ${span.name} ${span.spanContext().spanId} parent = ${span.parentSpanId || ''}`, + // ); + } + + onEnd(span: ReadableSpan): void { + this._queue.enqueue(span); + // console.info( + // `[Ennnd] ${span.name} ${span.spanContext().spanId}, runId=${span.attributes['langchain-run-id']}`, + // ); + } + + async forceFlush() { + await this._queue.destroy(); + await this._exporter.forceFlush(); + } + + async shutdown() { + await this._queue.destroy(); + await this._exporter.shutdown(); + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/schema.ts b/packages/cozeloop-langchain/src/callbacks/schema.ts new file mode 100644 index 0000000..a51dbfb --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/schema.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; + +function formatPropertyUnprovidedError(propName: string, envKey: string) { + return `${propName} not provided, neither pass it or set it via process.env.${envKey}`; +} + +const DEFAULT_BATCH_SIZE = 100; +const DEFAULT_SCHEDULE_DELAY = 30_000; + +export const CozeloopSpanProcessorOptionsSchema = z.object({ + /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ + workspaceId: z + .string() + .default(process.env.COZELOOP_WORKSPACE_ID || '') + .refine(val => Boolean(val), { + message: formatPropertyUnprovidedError( + 'workspaceId', + 'COZELOOP_WORKSPACE_ID', + ), + }), + /** Endpoint to export traces, use process.env.OTEL_EXPORTER_OTLP_ENDPOINT when unprovided */ + traceEndpoint: z + .string() + .default(process.env.OTEL_EXPORTER_OTLP_ENDPOINT || '') + .refine(val => Boolean(val), { + message: formatPropertyUnprovidedError( + 'traceEndpoint', + 'OTEL_EXPORTER_OTLP_ENDPOINT', + ), + }), + /** CozeLoop API token, use process.env.COZELOOP_API_TOKEN when unprovided */ + token: z + .string() + .default(process.env.COZELOOP_API_TOKEN || '') + .refine(val => Boolean(val), { + message: formatPropertyUnprovidedError('token', 'COZELOOP_API_TOKEN'), + }), + /** Export passthrough headers */ + headers: z.record(z.string(), z.string()).default({}), + /** Export batch size, default to `100` */ + batchSize: z.number().gt(0).default(DEFAULT_BATCH_SIZE), + /** Export batch report delay, default to `20`ms */ + scheduleDelay: z.number().gt(0).default(DEFAULT_SCHEDULE_DELAY), +}); + +export type CozeloopSpanProcessorOptions = z.infer< + typeof CozeloopSpanProcessorOptionsSchema +>; diff --git a/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts b/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts new file mode 100644 index 0000000..3a09b0a --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts @@ -0,0 +1,66 @@ +import { + type ReadableSpan, + type Span, + type SpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { type Context } from '@opentelemetry/api'; + +interface TreeSpan { + name: string; + id: string; + parent?: string; +} + +// 将 TreeSpan[] 转换成对象形式,方便检索 +function buildTree(data: TreeSpan[]): { [key: string]: TreeSpan } { + const treeMap: { [key: string]: TreeSpan } = {}; + data.forEach(item => { + treeMap[item.id] = item; + }); + return treeMap; +} + +// 递归打印树状结构 +function printTree( + tree: { [key: string]: TreeSpan }, + parentId: string | undefined, + indent = 0, +) { + const childNodes = Object.values(tree).filter( + node => node.parent === parentId, + ); + childNodes.forEach(node => { + const root = node.parent ? '-' : '🟢'; + console.log(`${' '.repeat(indent)}${root} ${node.name} [${node.id}]`); + printTree(tree, node.id, indent + 1); + }); +} + +export class TreeLogSpanProcessor implements SpanProcessor { + private _treeSpans: TreeSpan[] = []; + + onStart(span: Span, parentContext: Context): void { + // no - op + } + + onEnd(span: ReadableSpan): void { + // no op + this._treeSpans.push({ + name: span.name, + id: span.spanContext().spanId, + parent: span.parentSpanId, + }); + } + + forceFlush(): Promise { + this._treeSpans = []; + return Promise.resolve(); + } + + shutdown(): Promise { + const tree = buildTree(this._treeSpans); + printTree(tree, undefined); + this._treeSpans = []; + return Promise.resolve(); + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/utils.ts b/packages/cozeloop-langchain/src/callbacks/utils.ts new file mode 100644 index 0000000..ed6771e --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/utils.ts @@ -0,0 +1,102 @@ +import { randomBytes } from 'node:crypto'; + +import { type Serialized } from '@langchain/core/dist/load/serializable'; + +export function generateUUID(): string { + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, it => { + const v = Number(it); + + return (v ^ (randomBytes(1)[0] & (15 >> (v / 4)))).toString(16); + }); +} + +export function stringifyVal(val: unknown): string { + switch (typeof val) { + case 'number': + case 'bigint': + return `${val}`; + case 'boolean': + return val ? 'true' : 'false'; + case 'string': + case 'symbol': + return val.toString(); + case 'object': { + if (val === null) { + return ''; + } + if (val instanceof Date) { + return val.toISOString(); + } + if (val instanceof Error) { + return val.message; + } + if (Array.isArray(val)) { + return val.map(it => stringifyVal(it)).join(','); + } + return JSON.stringify(val); + } + case 'undefined': + return ''; + case 'function': + return `function@${val.name}`; + default: + return ''; + } +} + +export function guessModelProvider(modelName: string) { + if (!modelName) { + return ''; + } + + const guessMap = { + Doubao: /doubao/i, + Gemini: /gemini/i, + Anthropic: /claude/i, + 'Open AI': /gpt|o\d/i, + Grok: /grok/i, + DeepSeek: /deepseek/i, + Qwen: /qwen/i, + MoonShot: /moonshot/i, + Ernie: /ernie/i, + Minimax: /minimax/i, + }; + + for (const [provider, reg] of Object.entries(guessMap)) { + if (reg.test(modelName)) { + return provider; + } + } + + return modelName; +} + +export function extractLLMAttributes( + llm: Serialized, + extraParams?: Record, + metadata?: Record, +) { + const mixed: Record = { + ...(extraParams?.invocation_params || {}), + ...(metadata || {}), + }; + + // guess model name + const name = + llm.id.at(-1) || + mixed.model || + mixed.modelName || + mixed.model_name || + mixed.model_id; + + return { + model_name: name as string | undefined, + model_provider: guessModelProvider(`${name}`), + top_p: mixed.top_p as number, + top_k: mixed.top_k as number, + temperature: mixed.temperature as number, + frequency_penalty: mixed.frequency_penalty as number, + presence_penalty: mixed.presence_penalty as number, + max_tokens: mixed.max_tokens as number, + }; +} diff --git a/packages/cozeloop-langchain/src/global.d.ts b/packages/cozeloop-langchain/src/global.d.ts new file mode 100644 index 0000000..0d6dc3a --- /dev/null +++ b/packages/cozeloop-langchain/src/global.d.ts @@ -0,0 +1,14 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +declare module 'process' { + global { + namespace NodeJS { + interface ProcessEnv { + /** SDK Version, which is injected via vitest or tsup from package.json */ + COZELOOP_VERSION: string; + COZELOOP_API_TOKEN?: string; + OTEL_EXPORTER_OTLP_ENDPOINT?: string; + } + } + } +} diff --git a/packages/cozeloop-langchain/src/index.ts b/packages/cozeloop-langchain/src/index.ts new file mode 100644 index 0000000..31e64fc --- /dev/null +++ b/packages/cozeloop-langchain/src/index.ts @@ -0,0 +1,2 @@ +export { CozeloopCallbackHandler } from './callbacks'; +export type { CozeloopCallbackHandlerInput } from './callbacks'; diff --git a/packages/cozeloop-langchain/tsconfig.build.json b/packages/cozeloop-langchain/tsconfig.build.json new file mode 100644 index 0000000..c47dcdf --- /dev/null +++ b/packages/cozeloop-langchain/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true + }, + "include": ["src"] +} diff --git a/packages/cozeloop-langchain/tsconfig.json b/packages/cozeloop-langchain/tsconfig.json new file mode 100644 index 0000000..1f7637f --- /dev/null +++ b/packages/cozeloop-langchain/tsconfig.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loop-infra/ts-config/tsconfig.node.json", + "compilerOptions": { + "outDir": "./dist", + "moduleResolution": "node", + "module": "ES2022", + "lib": ["es2015", "dom"], + "types": ["node", "vitest/globals"], + "target": "es2022", + "paths": { + "@cozeloop/langchain": ["./src/core/index.ts"] + }, + "experimentalDecorators": true, + "tsBuildInfoFile": "./temp/.tsbuildinfo" + }, + "include": [ + "src", + "__tests__", + "vitest.config.ts", + "tsup.config.ts", + "package.json" + ] +} diff --git a/packages/cozeloop-langchain/tsconfig.typings.json b/packages/cozeloop-langchain/tsconfig.typings.json new file mode 100644 index 0000000..83e17ec --- /dev/null +++ b/packages/cozeloop-langchain/tsconfig.typings.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "baseUrl": ".", + "outDir": "./dist/typings", + "sourceMap": false, + "composite": false, + "incremental": false, + "emitDeclarationOnly": true, + "tsBuildInfoFile": "./temp/typing.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/packages/cozeloop-langchain/tsup.config.ts b/packages/cozeloop-langchain/tsup.config.ts new file mode 100644 index 0000000..b97c62a --- /dev/null +++ b/packages/cozeloop-langchain/tsup.config.ts @@ -0,0 +1,31 @@ +import { rmdirSync } from 'node:fs'; + +import { defineConfig } from 'tsup'; + +import packageJson from './package.json'; + +function clearOutDir(outDir: string) { + try { + rmdirSync(outDir); + } catch { + // no-catch + } +} + +export default defineConfig(() => { + const outDir = 'dist'; + clearOutDir(outDir); + + return { + entry: ['./src/index.ts'], + outDir, + splitting: false, + tsconfig: './tsconfig.build.json', + format: ['cjs', 'esm'], + dts: false, + onSuccess: 'tsc -b ./tsconfig.typings.json', + env: { + COZELOOP_VERSION: packageJson.version, + }, + }; +}); diff --git a/packages/cozeloop-langchain/vitest.config.ts b/packages/cozeloop-langchain/vitest.config.ts new file mode 100644 index 0000000..dc54aee --- /dev/null +++ b/packages/cozeloop-langchain/vitest.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from '@loop-infra/vitest-config'; + +import packageJson from './package.json'; + +export default defineConfig({ + dirname: __dirname, + preset: 'node', + test: { + testTimeout: 120_000, + coverage: { + all: false, + exclude: ['tsup.config.ts'], + }, + env: { + COZELOOP_VERSION: packageJson.version, + COZELOOP_WORKSPACE_ID: '7480080243929694252', + // OTEL_EXPORTER_OTLP_ENDPOINT: + // 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', + COZELOOP_API_TOKEN: + 'pat_zaguQ3FIZNicL5GZUgoSgLeXRg7dieSxpSWaQ4DaE3YKMr30dwzNz829Qqg5qJl0', + }, + }, +}); diff --git a/rush.json b/rush.json index b0c2e17..e3d8889 100644 --- a/rush.json +++ b/rush.json @@ -421,6 +421,10 @@ "packageName": "@cozeloop/ai", "projectFolder": "packages/cozeloop-ai" }, + { + "packageName": "@cozeloop/langchain", + "projectFolder": "packages/cozeloop-langchain" + }, { "packageName": "@cozeloop/ai-node-example", "projectFolder": "examples/cozeloop-ai-node" From fd6de15f53f6fb8f9f7d1b098b44862752803bdb Mon Sep 17 00:00:00 2001 From: qihai Date: Mon, 30 Jun 2025 15:29:34 +0800 Subject: [PATCH 02/13] feat(ci-tools): init 0.0.1 --- common/config/rush/pnpm-lock.yaml | 171 +++++++++++++++++---- packages/ci-tools/CHANGELOG.md | 4 + packages/ci-tools/LICENSE | 21 +++ packages/ci-tools/README.md | 80 ++++++++++ packages/ci-tools/bin/index.js | 3 + packages/ci-tools/eslint.config.js | 10 ++ packages/ci-tools/package.json | 57 +++++++ packages/ci-tools/src/global.d.ts | 12 ++ packages/ci-tools/src/index.ts | 22 +++ packages/ci-tools/src/lark/index.ts | 36 +++++ packages/ci-tools/src/lark/schema.ts | 24 +++ packages/ci-tools/src/lark/send-message.ts | 37 +++++ packages/ci-tools/src/lark/sync-issue.ts | 117 ++++++++++++++ packages/ci-tools/tsconfig.build.json | 10 ++ packages/ci-tools/tsconfig.json | 21 +++ packages/ci-tools/tsup.config.ts | 29 ++++ rush.json | 4 + 17 files changed, 626 insertions(+), 32 deletions(-) create mode 100644 packages/ci-tools/CHANGELOG.md create mode 100644 packages/ci-tools/LICENSE create mode 100644 packages/ci-tools/README.md create mode 100644 packages/ci-tools/bin/index.js create mode 100644 packages/ci-tools/eslint.config.js create mode 100644 packages/ci-tools/package.json create mode 100644 packages/ci-tools/src/global.d.ts create mode 100644 packages/ci-tools/src/index.ts create mode 100644 packages/ci-tools/src/lark/index.ts create mode 100644 packages/ci-tools/src/lark/schema.ts create mode 100644 packages/ci-tools/src/lark/send-message.ts create mode 100644 packages/ci-tools/src/lark/sync-issue.ts create mode 100644 packages/ci-tools/tsconfig.build.json create mode 100644 packages/ci-tools/tsconfig.json create mode 100644 packages/ci-tools/tsup.config.ts diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 539a9e1..fd4069d 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -226,7 +226,7 @@ importers: version: 1.9.0 openai: specifier: ^4.92.0 - version: 4.92.1(zod@3.25.67) + version: 4.92.1(ws@8.18.3)(zod@3.25.67) devDependencies: '@loop-infra/eslint-config': specifier: workspace:* @@ -253,6 +253,49 @@ importers: specifier: ^5.5.3 version: 5.8.2 + ../../packages/ci-tools: + dependencies: + '@larksuiteoapi/node-sdk': + specifier: ^1.50.1 + version: 1.50.1 + commander: + specifier: ^14.0.0 + version: 14.0.0 + zod: + specifier: ^3.25.67 + version: 3.25.67 + devDependencies: + '@loop-infra/eslint-config': + specifier: workspace:* + version: link:../../config/eslint-config + '@loop-infra/ts-config': + specifier: workspace:* + version: link:../../config/ts-config + '@loop-infra/vitest-config': + specifier: workspace:* + version: link:../../config/vitest-config + '@types/node': + specifier: ^20 + version: 20.17.22 + '@vitest/coverage-v8': + specifier: ~2.1.4 + version: 2.1.9(vitest@2.1.9(@types/node@20.17.22)(happy-dom@15.11.7)(msw@2.7.3(@types/node@20.17.22)(typescript@5.8.2))) + msw: + specifier: ^2.7.3 + version: 2.7.3(@types/node@20.17.22)(typescript@5.8.2) + rimraf: + specifier: ~3.0.2 + version: 3.0.2 + tsup: + specifier: ^8.0.1 + version: 8.4.0(postcss@8.5.3)(tsx@4.19.3)(typescript@5.8.2)(yaml@2.8.0) + typescript: + specifier: ^5.5.3 + version: 5.8.2 + vitest: + specifier: ~2.1.4 + version: 2.1.9(@types/node@20.17.22)(happy-dom@15.11.7)(msw@2.7.3(@types/node@20.17.22)(typescript@5.8.2)) + ../../packages/cozeloop-ai: dependencies: '@opentelemetry/api': @@ -367,9 +410,6 @@ importers: '@opentelemetry/sdk-trace-node': specifier: ~1.30.1 version: 1.30.1(@opentelemetry/api@1.9.0) - nanoid: - specifier: ^3.x - version: 3.3.8 remeda: specifier: ^2.21.2 version: 2.21.2 @@ -379,13 +419,13 @@ importers: devDependencies: '@langchain/core': specifier: ^0.3.61 - version: 0.3.61(openai@5.7.0(zod@3.25.67)) + version: 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) '@langchain/langgraph': specifier: ^0.3.1 - version: 0.3.1(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67)) + version: 0.3.1(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67)) '@langchain/openai': specifier: ^0.5.12 - version: 0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + version: 0.5.15(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(ws@8.18.3) '@loop-infra/eslint-config': specifier: workspace:* version: link:../../config/eslint-config @@ -403,7 +443,7 @@ importers: version: 2.1.9(vitest@2.1.9(@types/node@20.17.22)(happy-dom@15.11.7)(msw@2.7.3(@types/node@20.17.22)(typescript@5.8.2))) langchain: specifier: ^0.3.28 - version: 0.3.29(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(zod@3.25.67)) + version: 0.3.29(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(ws@8.18.3)(zod@3.25.67))(ws@8.18.3) msw: specifier: ^2.7.3 version: 2.7.3(@types/node@20.17.22)(typescript@5.8.2) @@ -1608,6 +1648,9 @@ packages: peerDependencies: '@langchain/core': '>=0.2.21 <0.4.0' + '@larksuiteoapi/node-sdk@1.50.1': + resolution: {integrity: sha512-hhB+HpTNl4HED9WGzL+2EhU4zQBYVtbTxVHBagohIm5iryNx1g0y7S3l6uAB54vbsnLKRireiH/r+bkV8ViXRg==} + '@microsoft/tsdoc-config@0.16.2': resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} @@ -2386,6 +2429,9 @@ packages: resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} engines: {node: '>=4'} + axios@0.27.2: + resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==} + axios@1.9.0: resolution: {integrity: sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==} @@ -2540,6 +2586,10 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@14.0.0: + resolution: {integrity: sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA==} + engines: {node: '>=20'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -3588,6 +3638,9 @@ packages: resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.identity@3.0.0: + resolution: {integrity: sha512-AupTIzdLQxJS5wIYUQlgGyk2XRTfGXA+MCghDHqZk0pzUNYvd3EESS6dkChNauNYVIutcb0dfHw1ri9Q1yPV8Q==} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -3612,6 +3665,9 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.pickby@4.6.0: + resolution: {integrity: sha512-AZV+GsS/6ckvPOVQPXSiFFacKvKB4kOQu6ynt9wz0F3LO4R9Ij4K1ddYsIytDpSgLz88JHd9P+oaLeej5/Sl7Q==} + lodash.sortby@4.7.0: resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} @@ -3986,6 +4042,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + querystringify@2.2.0: resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} @@ -4675,6 +4735,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.18.3: + resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + 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'} @@ -5907,14 +5979,14 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))': + '@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67))': dependencies: '@cfworker/json-schema': 4.1.1 ansi-styles: 5.2.0 camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.20 - langsmith: 0.3.33(openai@5.7.0(zod@3.25.67)) + langsmith: 0.3.33(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -5924,25 +5996,25 @@ snapshots: transitivePeerDependencies: - openai - '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + '@langchain/langgraph-checkpoint@0.0.18(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))': dependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) uuid: 10.0.0 - '@langchain/langgraph-sdk@0.0.83(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + '@langchain/langgraph-sdk@0.0.83(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))': dependencies: '@types/json-schema': 7.0.15 p-queue: 6.6.2 p-retry: 4.6.2 uuid: 9.0.1 optionalDependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) - '@langchain/langgraph@0.3.1(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67))': + '@langchain/langgraph@0.3.1(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(zod-to-json-schema@3.24.5(zod@3.25.67))': dependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) - '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) - '@langchain/langgraph-sdk': 0.0.83(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) + '@langchain/langgraph-checkpoint': 0.0.18(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67))) + '@langchain/langgraph-sdk': 0.0.83(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67))) uuid: 10.0.0 zod: 3.25.67 optionalDependencies: @@ -5950,20 +6022,34 @@ snapshots: transitivePeerDependencies: - react - '@langchain/openai@0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + '@langchain/openai@0.5.15(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(ws@8.18.3)': dependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) js-tiktoken: 1.0.20 - openai: 5.7.0(zod@3.25.67) + openai: 5.7.0(ws@8.18.3)(zod@3.25.67) zod: 3.25.67 transitivePeerDependencies: - ws - '@langchain/textsplitters@0.1.0(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))': + '@langchain/textsplitters@0.1.0(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))': dependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) js-tiktoken: 1.0.20 + '@larksuiteoapi/node-sdk@1.50.1': + dependencies: + axios: 0.27.2 + lodash.identity: 3.0.0 + lodash.merge: 4.6.2 + lodash.pickby: 4.6.0 + protobufjs: 7.4.0 + qs: 6.14.0 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + '@microsoft/tsdoc-config@0.16.2': dependencies: '@microsoft/tsdoc': 0.14.2 @@ -6916,6 +7002,13 @@ snapshots: axe-core@4.10.2: {} + axios@0.27.2: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + transitivePeerDependencies: + - debug + axios@1.9.0: dependencies: follow-redirects: 1.15.9 @@ -7107,6 +7200,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@14.0.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -8243,15 +8338,15 @@ snapshots: dependencies: json-buffer: 3.0.1 - langchain@0.3.29(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(zod@3.25.67)): + langchain@0.3.29(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(axios@1.9.0)(openai@5.7.0(ws@8.18.3)(zod@3.25.67))(ws@8.18.3): dependencies: - '@langchain/core': 0.3.61(openai@5.7.0(zod@3.25.67)) - '@langchain/openai': 0.5.15(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) - '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.61(openai@5.7.0(zod@3.25.67))) + '@langchain/core': 0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) + '@langchain/openai': 0.5.15(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67)))(ws@8.18.3) + '@langchain/textsplitters': 0.1.0(@langchain/core@0.3.61(openai@5.7.0(ws@8.18.3)(zod@3.25.67))) js-tiktoken: 1.0.20 js-yaml: 4.1.0 jsonpointer: 5.0.1 - langsmith: 0.3.33(openai@5.7.0(zod@3.25.67)) + langsmith: 0.3.33(openai@5.7.0(ws@8.18.3)(zod@3.25.67)) openapi-types: 12.1.3 p-retry: 4.6.2 uuid: 10.0.0 @@ -8263,7 +8358,7 @@ snapshots: - openai - ws - langsmith@0.3.33(openai@5.7.0(zod@3.25.67)): + langsmith@0.3.33(openai@5.7.0(ws@8.18.3)(zod@3.25.67)): dependencies: '@types/uuid': 10.0.0 chalk: 4.1.2 @@ -8273,7 +8368,7 @@ snapshots: semver: 7.7.1 uuid: 10.0.0 optionalDependencies: - openai: 5.7.0(zod@3.25.67) + openai: 5.7.0(ws@8.18.3)(zod@3.25.67) language-subtag-registry@0.3.23: {} @@ -8308,6 +8403,8 @@ snapshots: lodash.get@4.4.2: {} + lodash.identity@3.0.0: {} + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -8324,6 +8421,8 @@ snapshots: lodash.once@4.1.1: {} + lodash.pickby@4.6.0: {} + lodash.sortby@4.7.0: {} lodash.union@4.6.0: {} @@ -8498,7 +8597,7 @@ snapshots: dependencies: wrappy: 1.0.2 - openai@4.92.1(zod@3.25.67): + openai@4.92.1(ws@8.18.3)(zod@3.25.67): dependencies: '@types/node': 18.19.86 '@types/node-fetch': 2.6.12 @@ -8508,12 +8607,14 @@ snapshots: formdata-node: 4.4.1 node-fetch: 2.7.0 optionalDependencies: + ws: 8.18.3 zod: 3.25.67 transitivePeerDependencies: - encoding - openai@5.7.0(zod@3.25.67): + openai@5.7.0(ws@8.18.3)(zod@3.25.67): optionalDependencies: + ws: 8.18.3 zod: 3.25.67 openapi-types@12.1.3: {} @@ -8678,6 +8779,10 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + querystringify@2.2.0: {} queue-microtask@1.2.3: {} @@ -9454,6 +9559,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.18.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/packages/ci-tools/CHANGELOG.md b/packages/ci-tools/CHANGELOG.md new file mode 100644 index 0000000..115a5a8 --- /dev/null +++ b/packages/ci-tools/CHANGELOG.md @@ -0,0 +1,4 @@ +# 🕗 Change Log - @cozeloop/ci-tools + +## 0.0.1 +🌱 Initial version diff --git a/packages/ci-tools/LICENSE b/packages/ci-tools/LICENSE new file mode 100644 index 0000000..8badb49 --- /dev/null +++ b/packages/ci-tools/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + +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/packages/ci-tools/README.md b/packages/ci-tools/README.md new file mode 100644 index 0000000..8eb5ac8 --- /dev/null +++ b/packages/ci-tools/README.md @@ -0,0 +1,80 @@ +# 🧭 CozeLoop CI tools + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +CI tools. + +## Quick Start + +### 1. Installation + +```sh +npm install -g @cozeloop/ci-tools +``` + +### 2. Basic Usage + +1. Environment variables + +|Param|Comment|Example| +|----|----|------| +|LARK_APP_ID|Lark app id, `secrets.LARK_APP_ID`|'cli_xxx' +|LARK_APP_SECRET|Lark app secret, `secrets.LARK_APP_SECRET`|'xxx' +|ISSUE_ACTION|Github issue action, `github.event.action`|'opened' +|ISSUE_NUMBER|Github issue number, `github.event.issue.number`|'3' +|ISSUE_URL|Github issue html url, github.event.issue.html_url|'https://github.com/coze-dev/cozeloop-python/issues/3' +|ISSUE_TITLE|Github issue title, `github.event.issue.title`|'如何将coze智能体的数据通过cozeloop上报' +|ISSUE_BODY|Github issue body, `github.event.issue.body`|'请官方给出coze智能体上报到cozeloop的样例' +|REPO_NAME|Github repo name, `github.repository`|'coze-dev/cozeloop-python' + +2. `cozeloop-ci` Commands + +> Command usage: +> * global command: run `cozeloop-ci -h` after installing @cozeloop/ci-tools +> * using npx: `npx -p @cozeloop/ci-tools cozeloop-ci -h` + +* Overview: `cozeloop-ci -h` +* lark related: `cozeloop-ci lark [send-message|sync-issue] -h` + + +### 3. CI Usage + +🌰 notify via lark when issue opened, reopened or closed. + +```yaml +name: Issue sync + +on: + issues: + types: ['opened', 'reopened', 'closed'] + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + env: + NODE_VERSION: '18' + LARK_APP_ID: ${{ secrets.LARK_APP_ID }} + LARK_APP_SECRET: ${{ secrets.LARK_APP_SECRET }} + ISSUE_ACTION: ${{ github.event.action }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_URL: ${{ github.event.issue.html_url }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + REPO_NAME: ${{ github.repository }} + + steps: + - name: Setup Node + uses: actions/setup-node@v3 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Install dep + run: | + npm install -g @cozeloop/ci-tools@0.0.1 + + - name: Notification via lark + run: | + cozeloop-ci lark sync-issue \ + --email qihai@bytedance.com +``` diff --git a/packages/ci-tools/bin/index.js b/packages/ci-tools/bin/index.js new file mode 100644 index 0000000..84350d2 --- /dev/null +++ b/packages/ci-tools/bin/index.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +require('../dist/index.js').run(); diff --git a/packages/ci-tools/eslint.config.js b/packages/ci-tools/eslint.config.js new file mode 100644 index 0000000..4efcdeb --- /dev/null +++ b/packages/ci-tools/eslint.config.js @@ -0,0 +1,10 @@ +const { defineConfig } = require('@loop-infra/eslint-config'); + +module.exports = defineConfig({ + packageRoot: __dirname, + preset: 'node', + rules: { + '@typescript-eslint/naming-convention': 'off', + 'max-params': ['warn', { max: 4 }], + }, +}); diff --git a/packages/ci-tools/package.json b/packages/ci-tools/package.json new file mode 100644 index 0000000..00b5ef3 --- /dev/null +++ b/packages/ci-tools/package.json @@ -0,0 +1,57 @@ +{ + "name": "@cozeloop/ci-tools", + "version": "0.0.1", + "description": "🔧 Tools for CI", + "homepage": "https://github.com/coze-dev/cozeloop-js/tree/main/packages/ci-tools", + "bugs": { + "url": "https://github.com/coze-dev/cozeloop-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/coze-dev/cozeloop-js.git", + "directory": "packages/ci-tools" + }, + "license": "MIT", + "author": "qihai ", + "main": "dist/index.js", + "bin": { + "cozeloop-ci": "./bin/index.js" + }, + "files": [ + "dist", + "bin", + "LICENSE", + "README.md", + "CHANGELOG.md" + ], + "scripts": { + "build": "tsup", + "build:watch": "tsup --watch", + "lint": "eslint ./ --cache", + "prepublishOnly": "npm run build", + "start": "vitest watch", + "test": "vitest run", + "test:cov": "vitest run --coverage", + "vitest": "vitest" + }, + "dependencies": { + "@larksuiteoapi/node-sdk": "^1.50.1", + "commander": "^14.0.0", + "zod": "^3.25.67" + }, + "devDependencies": { + "@loop-infra/eslint-config": "workspace:*", + "@loop-infra/ts-config": "workspace:*", + "@loop-infra/vitest-config": "workspace:*", + "@types/node": "^20", + "@vitest/coverage-v8": "~2.1.4", + "msw": "^2.7.3", + "rimraf": "~3.0.2", + "tsup": "^8.0.1", + "typescript": "^5.5.3", + "vitest": "~2.1.4" + }, + "peerDependencies": { + "axios": "^1.7.1" + } +} diff --git a/packages/ci-tools/src/global.d.ts b/packages/ci-tools/src/global.d.ts new file mode 100644 index 0000000..65a5b3f --- /dev/null +++ b/packages/ci-tools/src/global.d.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT +declare module 'process' { + global { + namespace NodeJS { + interface ProcessEnv { + /** SDK Version, which is injected via vitest or tsup from package.json */ + COZELOOP_VERSION: string; + } + } + } +} diff --git a/packages/ci-tools/src/index.ts b/packages/ci-tools/src/index.ts new file mode 100644 index 0000000..ed40217 --- /dev/null +++ b/packages/ci-tools/src/index.ts @@ -0,0 +1,22 @@ +import { Command } from 'commander'; + +import { applyLarkCommand } from './lark'; + +function createProgram() { + const program = new Command('@cozeloop/ci-tools'); + + program + .name('cozeloop-ci') + .description('🔧 Cozeloop CI tools.') + .version(process.env.COZELOOP_VERSION); + + return program; +} + +export function run() { + const program = createProgram(); + + applyLarkCommand(program); + + program.parse(process.argv); +} diff --git a/packages/ci-tools/src/lark/index.ts b/packages/ci-tools/src/lark/index.ts new file mode 100644 index 0000000..b77cbd0 --- /dev/null +++ b/packages/ci-tools/src/lark/index.ts @@ -0,0 +1,36 @@ +import { Command, Option } from 'commander'; + +import { applySyncIssue } from './sync-issue'; +import { applySendMessage } from './send-message'; + +function createProgram() { + const program = new Command(); + + program.name('lark').description('Lark tools'); + + applySendMessage(program); + applySyncIssue(program); + + // add common options + program.commands.forEach(it => + it + .addOption(new Option('--app-id [appId]', 'app id').env('LARK_APP_ID')) + .addOption( + new Option('--app-secret [appSecret]', 'app secret').env( + 'LARK_APP_SECRET', + ), + ) + .addOption( + new Option('--app-type [appType]', 'app type').env('LARK_APP_TYPE'), + ) + .addOption(new Option('--domain [domain]', 'domain').env('LARK_DOMAIN')), + ); + + return program; +} + +export function applyLarkCommand(program: Command) { + program.addCommand(createProgram()); + + return program; +} diff --git a/packages/ci-tools/src/lark/schema.ts b/packages/ci-tools/src/lark/schema.ts new file mode 100644 index 0000000..a8235b1 --- /dev/null +++ b/packages/ci-tools/src/lark/schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod/v4'; +import { AppType, Domain } from '@larksuiteoapi/node-sdk'; + +export const larkOptionSchema = z.object({ + appId: z.string().prefault(process.env.LARK_APP_ID || ''), + appSecret: z.string().prefault(process.env.LARK_APP_SECRET || ''), + appType: z + .union([z.string(), z.number()]) + .prefault(process.env.LARK_APP_TYPE || AppType.SelfBuild) + .transform(val => Number(val)), + domain: z + .union([z.string(), z.number()]) + .prefault(process.env.LARK_DOMAIN || Domain.Feishu) + .transform(val => Number(val)), +}); + +export const syncIssueOptionsSchema = z + .object({ + email: z.array(z.string()), + chatId: z.array(z.string()), + }) + .refine(v => v.chatId.length || v.email.length, { + error: 'Neither email nor chatId is empty', + }); diff --git a/packages/ci-tools/src/lark/send-message.ts b/packages/ci-tools/src/lark/send-message.ts new file mode 100644 index 0000000..75dd4df --- /dev/null +++ b/packages/ci-tools/src/lark/send-message.ts @@ -0,0 +1,37 @@ +import { Command } from 'commander'; +import { Client } from '@larksuiteoapi/node-sdk'; + +import { larkOptionSchema } from './schema'; + +function safeJsonParse(val: string): T | undefined { + try { + return JSON.parse(val) as T; + } catch { + // no-catch + return undefined; + } +} + +async function sendMessage(this: Command) { + const options = larkOptionSchema.parse(this.opts); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- `im.message.create` does not export param type + const message = safeJsonParse(this.args[0]); + if (!message) { + throw new Error('Invalid message body'); + } + const client = new Client(options); + const resp = await await client.im.message.create(message); + + if (resp.code !== 0) { + throw new Error(resp.msg); + } +} + +export function applySendMessage(program: Command) { + program.addCommand( + new Command('send-message') + .description('Send message in JSON via lark') + .argument('', 'JSON message body to be sent') + .action(sendMessage), + ); +} diff --git a/packages/ci-tools/src/lark/sync-issue.ts b/packages/ci-tools/src/lark/sync-issue.ts new file mode 100644 index 0000000..2c752ca --- /dev/null +++ b/packages/ci-tools/src/lark/sync-issue.ts @@ -0,0 +1,117 @@ +import { Command } from 'commander'; +import { Client } from '@larksuiteoapi/node-sdk'; + +import { larkOptionSchema, syncIssueOptionsSchema } from './schema'; + +function makeIssueMessage() { + const issue_action = process.env.ISSUE_ACTION; + const issue_number = process.env.ISSUE_NUMBER; + const issue_url = process.env.ISSUE_URL; + const issue_title = process.env.ISSUE_TITLE; + const issue_body = process.env.ISSUE_BODY; + const repo_name = process.env.REPO_NAME; + + return JSON.stringify({ + schema: '2.0', + config: { + update_multi: true, + style: { + text_size: { + normal_v2: { + default: 'normal', + pc: 'normal', + mobile: 'heading', + }, + }, + }, + }, + body: { + direction: 'vertical', + padding: '12px 12px 12px 12px', + elements: [ + { + tag: 'markdown', + content: [ + `> 仓库:[${repo_name}](https://github.com/${repo_name})\n\n`, + `
${issue_title} [#${issue_number}](${issue_url})
`, + `${issue_body}\n\n`, + `👉 前往处理`, + ].join('\n'), + text_align: 'left', + text_size: 'normal_v2', + margin: '0px 0px 0px 0px', + }, + { + tag: 'markdown', + content: '', + text_align: 'left', + text_size: 'normal_v2', + margin: '0px 0px 0px 0px', + }, + ], + }, + header: { + title: { + tag: 'plain_text', + content: `📢 Issue #${issue_number} ${issue_action}`, + }, + subtitle: { + tag: 'plain_text', + content: '', + }, + template: 'blue', + padding: '12px 12px 12px 12px', + }, + }); +} + +async function syncIssue(this: Command) { + const options = syncIssueOptionsSchema.parse(this.opts()); + const clientOptions = larkOptionSchema.parse(this.opts()); + + const client = new Client(clientOptions); + const content = makeIssueMessage(); + let success = 0; + const errors: string[] = []; + + const sendMessage = async (type: 'email' | 'chat_id', id: string) => { + const resp = await client.im.message.create({ + params: { + receive_id_type: type, + }, + data: { + receive_id: id, + content, + msg_type: 'interactive', + }, + }); + + if (resp.code === 0) { + success++; + } else { + errors.push(`Error send to ${it}, errMsg=${resp.msg}`); + } + }; + + const tasks: Promise[] = []; + options.email.forEach(it => tasks.push(sendMessage('email', it))); + options.chatId.forEach(it => tasks.push(sendMessage('chat_id', it))); + await Promise.allSettled(tasks); + + const result = + success === tasks.length + ? '✅ All done' + : `✅ Success ${success}\n❌ Fail ${errors.length}, errMsg=\n${errors.join('\n')}`; + + console.info(result); +} + +export function applySyncIssue(program: Command) { + program.addCommand( + new Command('sync-issue') + .description('Synchronize GitHub issue via lark') + .option('--email [emails...]', 'email list split by `,`', []) + .option('--chat-id [chat-ids...]', 'chat id list split by `,`', []) + .action(syncIssue), + ); +} diff --git a/packages/ci-tools/tsconfig.build.json b/packages/ci-tools/tsconfig.build.json new file mode 100644 index 0000000..c47dcdf --- /dev/null +++ b/packages/ci-tools/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "experimentalDecorators": true + }, + "include": ["src"] +} diff --git a/packages/ci-tools/tsconfig.json b/packages/ci-tools/tsconfig.json new file mode 100644 index 0000000..fb104e8 --- /dev/null +++ b/packages/ci-tools/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "@loop-infra/ts-config/tsconfig.node.json", + "compilerOptions": { + "outDir": "./dist", + "moduleResolution": "node", + "module": "ES2022", + "lib": ["es2015", "dom"], + "types": ["node", "vitest/globals"], + "target": "es2022", + "experimentalDecorators": true, + "tsBuildInfoFile": "./temp/.tsbuildinfo" + }, + "include": [ + "src", + "__tests__", + "vitest.config.ts", + "tsup.config.ts", + "package.json" + ] +} diff --git a/packages/ci-tools/tsup.config.ts b/packages/ci-tools/tsup.config.ts new file mode 100644 index 0000000..e6a590e --- /dev/null +++ b/packages/ci-tools/tsup.config.ts @@ -0,0 +1,29 @@ +import { rmdirSync } from 'node:fs'; + +import { defineConfig } from 'tsup'; + +import packageJson from './package.json'; + +function clearOutDir(outDir: string) { + try { + rmdirSync(outDir); + } catch { + // no-catch + } +} + +export default defineConfig(() => { + const outDir = 'dist'; + clearOutDir(outDir); + + return { + entry: ['./src/index.ts'], + outDir, + splitting: false, + tsconfig: './tsconfig.build.json', + dts: false, + env: { + COZELOOP_VERSION: packageJson.version, + }, + }; +}); diff --git a/rush.json b/rush.json index e3d8889..a94da77 100644 --- a/rush.json +++ b/rush.json @@ -425,6 +425,10 @@ "packageName": "@cozeloop/langchain", "projectFolder": "packages/cozeloop-langchain" }, + { + "packageName": "@cozeloop/ci-tools", + "projectFolder": "packages/ci-tools" + }, { "packageName": "@cozeloop/ai-node-example", "projectFolder": "examples/cozeloop-ai-node" From 070751fbfa5528683b9379e07c4dc32dcecd2c26 Mon Sep 17 00:00:00 2001 From: qihai Date: Tue, 1 Jul 2025 20:25:21 +0800 Subject: [PATCH 03/13] feat(cozeloop-langchain): parse langchain format --- common/config/rush/pnpm-lock.yaml | 3 - cspell.json | 1 + packages/ci-tools/.npmrc | 1 + packages/cozeloop-langchain/.npmrc | 1 + .../__tests__/__mock__/index.ts | 1 + .../__tests__/callback-utils/common.test.ts | 39 ++++ .../model.test.ts} | 39 +--- .../__tests__/callback.test.ts | 75 ++++---- packages/cozeloop-langchain/package.json | 1 - .../src/callbacks/callback-handler.ts | 92 +++++---- .../src/callbacks/schema.ts | 14 +- .../src/callbacks/tree-log-processor.ts | 1 - .../src/callbacks/utils/chain.ts | 27 +++ .../src/callbacks/utils/common.ts | 41 ++++ .../src/callbacks/utils/index.ts | 8 + .../src/callbacks/utils/message.ts | 178 ++++++++++++++++++ .../callbacks/{utils.ts => utils/model.ts} | 94 ++++----- packages/cozeloop-langchain/tsconfig.json | 3 +- 18 files changed, 454 insertions(+), 165 deletions(-) create mode 100644 packages/ci-tools/.npmrc create mode 100644 packages/cozeloop-langchain/.npmrc create mode 100644 packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts rename packages/cozeloop-langchain/__tests__/{utils.test.ts => callback-utils/model.test.ts} (60%) create mode 100644 packages/cozeloop-langchain/src/callbacks/utils/chain.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/utils/common.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/utils/index.ts create mode 100644 packages/cozeloop-langchain/src/callbacks/utils/message.ts rename packages/cozeloop-langchain/src/callbacks/{utils.ts => utils/model.ts} (51%) diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index fd4069d..c984d85 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -410,9 +410,6 @@ importers: '@opentelemetry/sdk-trace-node': specifier: ~1.30.1 version: 1.30.1(@opentelemetry/api@1.9.0) - remeda: - specifier: ^2.21.2 - version: 2.21.2 zod: specifier: ^3.25.67 version: 3.25.67 diff --git a/cspell.json b/cspell.json index 258039e..f220de4 100644 --- a/cspell.json +++ b/cspell.json @@ -15,6 +15,7 @@ "langsmith", "loggable", "packagejson", + "prefault", "preinstall", "remeda", "traceparent", diff --git a/packages/ci-tools/.npmrc b/packages/ci-tools/.npmrc new file mode 100644 index 0000000..38f11c6 --- /dev/null +++ b/packages/ci-tools/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/packages/cozeloop-langchain/.npmrc b/packages/cozeloop-langchain/.npmrc new file mode 100644 index 0000000..38f11c6 --- /dev/null +++ b/packages/cozeloop-langchain/.npmrc @@ -0,0 +1 @@ +registry=https://registry.npmjs.org diff --git a/packages/cozeloop-langchain/__tests__/__mock__/index.ts b/packages/cozeloop-langchain/__tests__/__mock__/index.ts index 7d6b0c1..97b8865 100644 --- a/packages/cozeloop-langchain/__tests__/__mock__/index.ts +++ b/packages/cozeloop-langchain/__tests__/__mock__/index.ts @@ -1,2 +1,3 @@ export { CustomLLM } from './custom-model'; export { CustomRetriever } from './custom-retriever'; +export { reactAgentExecutor } from './react-agent'; diff --git a/packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts b/packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts new file mode 100644 index 0000000..d72e224 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts @@ -0,0 +1,39 @@ +import { + generateUUID, + stringifyVal, +} from '@cozeloop/langchain/callbacks/utils'; + +describe('generateUUID', () => { + it('🧪 should generate a valid UUID string', () => { + const uuid = generateUUID(); + const pattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + expect(pattern.test(uuid)).toBe(true); + }); +}); + +describe('stringifyVal', () => { + it('🧪 should stringify different types of values correctly', () => { + expect(stringifyVal(42)).toBe('42'); + expect(stringifyVal(3.14)).toBe('3.14'); + expect(stringifyVal(true)).toBe('true'); + expect(stringifyVal(false)).toBe('false'); + expect(stringifyVal('hello')).toBe('hello'); + expect(stringifyVal(Symbol('foo'))).toBe('Symbol(foo)'); + expect(stringifyVal(null)).toBe(''); + expect(stringifyVal(new Date('2023-04-01'))).toBe( + '2023-04-01T00:00:00.000Z', + ); + expect(stringifyVal(new Error('Something went wrong'))).toBe( + 'Something went wrong', + ); + expect(stringifyVal([1, 2, 3])).toBe('[1,2,3]'); + expect(stringifyVal({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + expect(stringifyVal(undefined)).toBe(''); + expect( + stringifyVal(() => { + /** noop */ + }), + ).toBe('function@'); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/utils.test.ts b/packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts similarity index 60% rename from packages/cozeloop-langchain/__tests__/utils.test.ts rename to packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts index d5371f5..f2d9b9e 100644 --- a/packages/cozeloop-langchain/__tests__/utils.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts @@ -1,46 +1,9 @@ import { type Serialized } from '@langchain/core/dist/load/serializable'; import { - generateUUID, - stringifyVal, guessModelProvider, extractLLMAttributes, -} from '../src/callbacks/utils'; - -describe('generateUUID', () => { - it('🧪 should generate a valid UUID string', () => { - const uuid = generateUUID(); - const pattern = - /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; - expect(pattern.test(uuid)).toBe(true); - }); -}); - -describe('stringifyVal', () => { - it('🧪 should stringify different types of values correctly', () => { - expect(stringifyVal(42)).toBe('42'); - expect(stringifyVal(3.14)).toBe('3.14'); - expect(stringifyVal(true)).toBe('true'); - expect(stringifyVal(false)).toBe('false'); - expect(stringifyVal('hello')).toBe('hello'); - expect(stringifyVal(Symbol('foo'))).toBe('Symbol(foo)'); - expect(stringifyVal(null)).toBe(''); - expect(stringifyVal(new Date('2023-04-01'))).toBe( - '2023-04-01T00:00:00.000Z', - ); - expect(stringifyVal(new Error('Something went wrong'))).toBe( - 'Something went wrong', - ); - expect(stringifyVal([1, 2, 3])).toBe('1,2,3'); - expect(stringifyVal({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); - expect(stringifyVal(undefined)).toBe(''); - expect( - stringifyVal(() => { - /** noop */ - }), - ).toBe('function@'); - }); -}); +} from '@cozeloop/langchain/callbacks/utils'; describe('guessModelProvider', () => { it('🧪 should guess the model provider correctly', () => { diff --git a/packages/cozeloop-langchain/__tests__/callback.test.ts b/packages/cozeloop-langchain/__tests__/callback.test.ts index 07c5ef1..ee5dc48 100644 --- a/packages/cozeloop-langchain/__tests__/callback.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback.test.ts @@ -1,30 +1,29 @@ import { ChatPromptTemplate } from '@langchain/core/prompts'; -import { reactAgentExecutor } from './__mock__/react-agent'; -import { CustomLLM } from './__mock__'; -import { CozeloopCallbackHandler } from '../src'; +import { + CozeloopCallbackHandler, + type CozeloopCallbackHandlerInput, +} from '@cozeloop/langchain'; +import { CustomLLM, CustomRetriever, reactAgentExecutor } from './__mock__'; + +const makeCallback = (input?: CozeloopCallbackHandlerInput) => + new CozeloopCallbackHandler({ + spanProcessor: { + traceEndpoint: + 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', + headers: { + 'x-tt-env': 'boe_otel_ingest_trace', + }, + }, + ...input, + }); describe('Callback Test', () => { it('🧪 invoke model', async () => { const prompt = ChatPromptTemplate.fromTemplate('What is 1 + {number}?'); const model = new CustomLLM({}); const chain = prompt.pipe(model); - const callback = new CozeloopCallbackHandler({ - spanProcessor: { - headers: { - 'x-tt-env': 'boe_otel_ingest_trace', - }, - traceEndpoint: - 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', - }, - // ignoreAgent: true, - // ignoreLLM: true, - // ignorePrompt: true, - // ignoreChain: true, - // ignoreCustomEvent: true, - // ignoreRetriever: true, - // raiseError: true, - }); + const callback = makeCallback(); const resp = await chain.invoke( { number: 1 }, @@ -38,33 +37,20 @@ describe('Callback Test', () => { await callback.shutdown(); }); - it('🧪 stream model', async () => { - const callback = new CozeloopCallbackHandler({ - spanProcessor: { - traceEndpoint: - 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', - }, - }); + it.only('🧪 stream model', async () => { + const callback = makeCallback(); const model = new CustomLLM({}); - const resp = await model.stream('123', { + const resp = await model.stream(['hi', '你好'], { callbacks: [callback], }); for await (const chunk of resp) { - console.info('receive', chunk); + expect(chunk).not.toBeUndefined(); } await callback.shutdown(); }); - it.only('🧪 react agent', async () => { - const callback = new CozeloopCallbackHandler({ - spanProcessor: { - traceEndpoint: - 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', - headers: { - 'x-tt-env': 'boe_otel_ingest_trace', - }, - }, - }); + it('🧪 react agent', async () => { + const callback = makeCallback(); const resp = await reactAgentExecutor.invoke( { input: '翻译「苹果」到英文' }, { callbacks: [callback] }, @@ -74,4 +60,17 @@ describe('Callback Test', () => { await callback.shutdown(); }); + + it('🧪 retriever', async () => { + const callback = makeCallback(); + const retriever = new CustomRetriever(); + + const resp = await retriever.invoke('苹果派做法', { + callbacks: [callback], + runName: '🧑‍🍳 烹饪大家', + }); + console.info(resp); + + await callback.shutdown(); + }); }); diff --git a/packages/cozeloop-langchain/package.json b/packages/cozeloop-langchain/package.json index aa7271c..80637d0 100644 --- a/packages/cozeloop-langchain/package.json +++ b/packages/cozeloop-langchain/package.json @@ -53,7 +53,6 @@ "@opentelemetry/sdk-node": "~0.57.2", "@opentelemetry/sdk-trace-base": "~1.30.1", "@opentelemetry/sdk-trace-node": "~1.30.1", - "remeda": "^2.21.2", "zod": "^3.25.67" }, "devDependencies": { diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts index a6fff87..11730b4 100644 --- a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -24,7 +24,16 @@ import { type HandleLLMNewTokenCallbackFields, } from '@langchain/core/callbacks/base'; -import { extractLLMAttributes, generateUUID, stringifyVal } from './utils'; +import { parseLLMPrompts } from './utils/message'; +import { + extractLLMAttributes, + generateUUID, + guessChainInput, + guessChainOutput, + parseBaseMessages, + parseLLMResult, + stringifyVal, +} from './utils'; import { TreeLogSpanProcessor } from './tree-log-processor'; import { type CozeloopSpanProcessorOptions } from './schema'; import { CozeloopSpanProcessor } from './processor'; @@ -36,22 +45,6 @@ export interface CozeloopCallbackHandlerInput extends BaseCallbackHandlerInput { spanProcessor?: Partial; } -// TODO: remove -export function logMethod( - target: any, - propertyKey: string, - descriptor: PropertyDescriptor, -) { - const originalMethod = descriptor.value; - - descriptor.value = function (...args: any[]) { - console.log('🟢', propertyKey, args); - return originalMethod.apply(this, args); - }; - - return descriptor; -} - export class CozeloopCallbackHandler extends BaseCallbackHandler implements CozeloopCallbackHandlerInput @@ -126,7 +119,7 @@ export class CozeloopCallbackHandler [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.AGENT, [CozeloopAttr.INPUT]: stringifyVal( // @ts-expect-error action has messageLog - stringifyVal(action.messageLog || action.log), + action.messageLog || action.log, ), }); }); @@ -139,7 +132,10 @@ export class CozeloopCallbackHandler tags?: string[], ): Promise | void { this._endSpan(runId, undefined, span => { - span.setAttribute(CozeloopAttr.OUTPUT, stringifyVal(action.returnValues)); + span.setAttribute( + CozeloopAttr.OUTPUT, + stringifyVal(guessChainOutput(action.returnValues)), + ); }); } @@ -177,6 +173,9 @@ export class CozeloopCallbackHandler this._startSpan(spanName, runId, parentRunId, span => { span.setAttributes({ [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.MODEL, + [CozeloopAttr.INPUT]: stringifyVal({ + messages: parseBaseMessages(messages), + }), [CozeloopAttr.MODEL_PROVIDER]: model_provider, [CozeloopAttr.REQUEST_MODEL]: model_name, [CozeloopAttr.RESPONSE_MODEL]: model_name, @@ -188,7 +187,6 @@ export class CozeloopCallbackHandler [CozeloopAttr.PRESENCE_PENALTY]: callOptions.presence_penalty, }); }); - this._endSpan(runId, undefined); } handleLLMStart( @@ -211,6 +209,7 @@ export class CozeloopCallbackHandler const spanName = `${runName || model_name || 'LLMStart'}`; this._startSpan(spanName, runId, parentRunId, span => { span.setAttributes({ + [CozeloopAttr.INPUT]: stringifyVal(parseLLMPrompts(prompts)), [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.MODEL, [CozeloopAttr.MODEL_PROVIDER]: model_provider, [CozeloopAttr.REQUEST_MODEL]: model_name, @@ -256,13 +255,17 @@ export class CozeloopCallbackHandler tags?: string[], extraParams?: Record, ) { + console.info(output.generations); this._endSpan(runId, undefined, span => { + const result = parseLLMResult(output); + span.setAttributes({ - [CozeloopAttr.OUTPUT]: stringifyVal(output?.generations), - // TODO - // [CozeloopAttr.INPUT_TOKENS]: stringifyVal(output?.llmOutput), - // [CozeloopAttr.OUTPUT_TOKENS]: stringifyVal(output.generations), - // [CozeloopAttr.TOTAL_TOKENS]: stringifyVal(output.generations), + [CozeloopAttr.OUTPUT]: stringifyVal(result), + [CozeloopAttr.INPUT_TOKENS]: stringifyVal(result?.usage.prompt_tokens), + [CozeloopAttr.OUTPUT_TOKENS]: stringifyVal( + result?.usage.completion_tokens, + ), + [CozeloopAttr.TOTAL_TOKENS]: stringifyVal(result?.usage.total_tokens), }); }); } @@ -306,7 +309,7 @@ export class CozeloopCallbackHandler this._startSpan(spanName, runId, parentRunId, span => { span.setAttributes({ [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.CHAIN, - [CozeloopAttr.INPUT]: stringifyVal(inputs), + [CozeloopAttr.INPUT]: stringifyVal(guessChainInput(inputs)), }); }); } @@ -324,7 +327,7 @@ export class CozeloopCallbackHandler } this._endSpan(runId, undefined, span => { span.setAttributes({ - [CozeloopAttr.OUTPUT]: stringifyVal(outputs), + [CozeloopAttr.OUTPUT]: stringifyVal(guessChainOutput(outputs)), }); }); } @@ -367,7 +370,7 @@ export class CozeloopCallbackHandler parentRunId?: string, tags?: string[], ) { - this._endSpan(runId, parentRunId, span => { + this._endSpan(runId, undefined, span => { span.setAttributes({ [CozeloopAttr.OUTPUT]: stringifyVal(output), }); @@ -392,7 +395,13 @@ export class CozeloopCallbackHandler metadata?: Record, name?: string, ) { - console.info('handleRetrieverStart'); + const spanName = retriever.name ?? name ?? 'Retriever'; + this._startSpan(spanName, runId, parentRunId, span => { + span.setAttributes({ + [CozeloopAttr.INPUT]: query, + [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.RETRIEVER, + }); + }); } handleRetrieverEnd( @@ -401,7 +410,11 @@ export class CozeloopCallbackHandler parentRunId?: string, tags?: string[], ) { - console.info('handleRetrieverEnd'); + this._endSpan(runId, undefined, span => { + span.setAttributes({ + [CozeloopAttr.OUTPUT]: stringifyVal(documents), + }); + }); } handleRetrieverError( @@ -410,7 +423,7 @@ export class CozeloopCallbackHandler parentRunId?: string, tags?: string[], ) { - console.info('handleRetrieverError'); + this._endSpan(runId, err || ''); } private handlePromptStart( @@ -427,7 +440,7 @@ export class CozeloopCallbackHandler this._startSpan(runName || 'Prompt', runId, parentRunId, span => { span.setAttributes({ [CozeloopAttr.SPAN_TYPE]: CozeloopSpanType.PROMPT, - [CozeloopAttr.INPUT]: stringifyVal(inputs), + [CozeloopAttr.INPUT]: stringifyVal(guessChainInput(inputs)), [CozeloopAttr.PROMPT_KEY]: '', [CozeloopAttr.PROMPT_VERSION]: '', [CozeloopAttr.PROMPT_PROVIDER]: 'LangChain', @@ -448,7 +461,7 @@ export class CozeloopCallbackHandler this._promptChain.delete(runId); this._endSpan(runId, undefined, span => { span.setAttributes({ - [CozeloopAttr.OUTPUT]: stringifyVal(outputs), + [CozeloopAttr.OUTPUT]: stringifyVal(guessChainOutput(outputs)), }); }); } @@ -467,6 +480,13 @@ export class CozeloopCallbackHandler this._endSpan(runId, err || ''); } + /** + * Starts span + * @param name - span name + * @param runId - run id + * @param parentRunId - parent run id + * @param cb - span operation + */ private _startSpan( name: string, runId: string, @@ -487,6 +507,12 @@ export class CozeloopCallbackHandler }); } + /** + * Ends span + * @param runId - run id + * @param err - `undefined` → ok, else → error + * @param cb - span operation + */ private _endSpan(runId: string, err: unknown, cb?: (span: Span) => void) { const span = this._runMap.get(runId); diff --git a/packages/cozeloop-langchain/src/callbacks/schema.ts b/packages/cozeloop-langchain/src/callbacks/schema.ts index a51dbfb..d63ee98 100644 --- a/packages/cozeloop-langchain/src/callbacks/schema.ts +++ b/packages/cozeloop-langchain/src/callbacks/schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from 'zod/v4'; function formatPropertyUnprovidedError(propName: string, envKey: string) { return `${propName} not provided, neither pass it or set it via process.env.${envKey}`; @@ -11,7 +11,7 @@ export const CozeloopSpanProcessorOptionsSchema = z.object({ /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ workspaceId: z .string() - .default(process.env.COZELOOP_WORKSPACE_ID || '') + .prefault(process.env.COZELOOP_WORKSPACE_ID || '') .refine(val => Boolean(val), { message: formatPropertyUnprovidedError( 'workspaceId', @@ -21,7 +21,7 @@ export const CozeloopSpanProcessorOptionsSchema = z.object({ /** Endpoint to export traces, use process.env.OTEL_EXPORTER_OTLP_ENDPOINT when unprovided */ traceEndpoint: z .string() - .default(process.env.OTEL_EXPORTER_OTLP_ENDPOINT || '') + .prefault(process.env.OTEL_EXPORTER_OTLP_ENDPOINT || '') .refine(val => Boolean(val), { message: formatPropertyUnprovidedError( 'traceEndpoint', @@ -31,16 +31,16 @@ export const CozeloopSpanProcessorOptionsSchema = z.object({ /** CozeLoop API token, use process.env.COZELOOP_API_TOKEN when unprovided */ token: z .string() - .default(process.env.COZELOOP_API_TOKEN || '') + .prefault(process.env.COZELOOP_API_TOKEN || '') .refine(val => Boolean(val), { message: formatPropertyUnprovidedError('token', 'COZELOOP_API_TOKEN'), }), /** Export passthrough headers */ - headers: z.record(z.string(), z.string()).default({}), + headers: z.record(z.string(), z.string()).prefault({}), /** Export batch size, default to `100` */ - batchSize: z.number().gt(0).default(DEFAULT_BATCH_SIZE), + batchSize: z.number().gt(0).prefault(DEFAULT_BATCH_SIZE), /** Export batch report delay, default to `20`ms */ - scheduleDelay: z.number().gt(0).default(DEFAULT_SCHEDULE_DELAY), + scheduleDelay: z.number().gt(0).prefault(DEFAULT_SCHEDULE_DELAY), }); export type CozeloopSpanProcessorOptions = z.infer< diff --git a/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts b/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts index 3a09b0a..3b270b1 100644 --- a/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts +++ b/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts @@ -44,7 +44,6 @@ export class TreeLogSpanProcessor implements SpanProcessor { } onEnd(span: ReadableSpan): void { - // no op this._treeSpans.push({ name: span.name, id: span.spanContext().spanId, diff --git a/packages/cozeloop-langchain/src/callbacks/utils/chain.ts b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts new file mode 100644 index 0000000..6943cdd --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts @@ -0,0 +1,27 @@ +import { type ChainValues } from '@langchain/core/utils/types'; + +export function guessChainInput(inputs: ChainValues) { + if (!inputs) { + return undefined; + } + + return inputs.input || inputs.inputs || inputs.question || inputs; +} + +export function guessChainOutput(outputs: ChainValues) { + if (!outputs) { + return undefined; + } + + if (outputs.returnValues) { + return guessChainOutput(outputs.returnValues); + } + + return ( + outputs.text || + outputs.answer || + outputs.output || + outputs.result || + outputs + ); +} diff --git a/packages/cozeloop-langchain/src/callbacks/utils/common.ts b/packages/cozeloop-langchain/src/callbacks/utils/common.ts new file mode 100644 index 0000000..56a58ac --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/utils/common.ts @@ -0,0 +1,41 @@ +import { randomBytes } from 'node:crypto'; + +export function generateUUID(): string { + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, it => { + const v = Number(it); + + return (v ^ (randomBytes(1)[0] & (15 >> (v / 4)))).toString(16); + }); +} + +export function stringifyVal(val: unknown): string { + switch (typeof val) { + case 'number': + case 'bigint': + return `${val}`; + case 'boolean': + return val ? 'true' : 'false'; + case 'string': + return val; + case 'symbol': + return val.toString(); + case 'object': { + if (val === null) { + return ''; + } + if (val instanceof Date) { + return val.toISOString(); + } + if (val instanceof Error) { + return val.message; + } + return JSON.stringify(val); + } + case 'undefined': + return ''; + case 'function': + return `function@${val.name}`; + default: + return ''; + } +} diff --git a/packages/cozeloop-langchain/src/callbacks/utils/index.ts b/packages/cozeloop-langchain/src/callbacks/utils/index.ts new file mode 100644 index 0000000..21c29b7 --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/utils/index.ts @@ -0,0 +1,8 @@ +export { guessChainInput, guessChainOutput } from './chain'; +export { generateUUID, stringifyVal } from './common'; +export { parseBaseMessages } from './message'; +export { + extractLLMAttributes, + guessModelProvider, + parseLLMResult, +} from './model'; diff --git a/packages/cozeloop-langchain/src/callbacks/utils/message.ts b/packages/cozeloop-langchain/src/callbacks/utils/message.ts new file mode 100644 index 0000000..acf6fd8 --- /dev/null +++ b/packages/cozeloop-langchain/src/callbacks/utils/message.ts @@ -0,0 +1,178 @@ +import { type Generation } from '@langchain/core/outputs'; +import { + BaseMessage, + type MessageContentComplex, + type AIMessageChunk, +} from '@langchain/core/messages'; + +import { stringifyVal } from './common'; + +interface LoopMessageContentPart { + type: 'text' | 'image_url' | 'file_url'; + text?: string; + image_url?: { + name?: string; + url?: string; + detail?: string; + }; + file_url?: { + name?: string; + url?: string; + detail?: string; + suffix?: string; + }; +} + +interface LoopToolCall { + id?: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; +} + +interface LoopMessage { + role: 'assistant' | 'user' | 'tool' | 'system'; + content?: string; + parts?: LoopMessageContentPart[]; + tool_calls?: LoopToolCall[]; +} + +function parseRole(message?: BaseMessage): LoopMessage['role'] { + switch (message?.getType()) { + case 'human': + return 'user'; + case 'ai': + return 'assistant'; + case 'system': + return 'system'; + case 'tool': + case 'function': + return 'tool'; + case 'remove': + case 'generic': + case 'developer': + default: + return 'assistant'; + } +} + +function parseContent(message?: BaseMessage) { + if (typeof message?.content === 'string') { + return message.content; + } + + return undefined; +} + +function parsePart(complex: MessageContentComplex): LoopMessageContentPart { + switch (complex.type) { + case 'text': + return { type: 'text', text: stringifyVal(complex.text) }; + case 'image_url': + return { + type: 'image_url', + image_url: + typeof complex.image_url === 'string' + ? { url: complex.image_url } + : { + name: complex.image_url?.name, + url: complex.image_url?.url, + detail: complex.image_url?.url, + }, + }; + case 'file_url': + return { + type: 'file_url', + file_url: { + name: complex.file_url?.name || complex.name, + url: complex.file_url?.url || complex.url, + }, + }; + default: + return { type: 'text', text: stringifyVal(complex) }; + } +} + +function parseParts( + message?: BaseMessage, +): LoopMessageContentPart[] | undefined { + if (!message?.content.length || typeof message.content === 'string') { + return undefined; + } + + return message.content.map(it => parsePart(it)); +} + +function parseToolCalls(message?: BaseMessage): LoopToolCall[] | undefined { + const toolCalls = (message as AIMessageChunk | undefined)?.tool_calls; + + if (!toolCalls?.length) { + return undefined; + } + + return toolCalls.map(it => ({ + id: it.id, + type: 'function', + function: { + name: it.name, + arguments: stringifyVal(it.args), + }, + })); +} + +export function parseRawMessage( + message?: BaseMessage | Generation | string, +): LoopMessage | string | undefined { + if (typeof message === 'undefined') { + return undefined; + } + + if (typeof message === 'string') { + return message; + } + + if (message instanceof BaseMessage) { + return parseBaseMessage(message); + } + + if ('message' in message) { + return parseBaseMessage(message.message as BaseMessage); + } + + return { role: 'assistant', content: message.text }; +} + +export function parseBaseMessage(message?: BaseMessage): LoopMessage { + return { + role: parseRole(message), + content: parseContent(message), + parts: parseParts(message), + tool_calls: parseToolCalls(message), + }; +} + +export function parseBaseMessages(messages?: BaseMessage[][]) { + if (!messages?.[0].length) { + return undefined; + } + + const loopMessages: LoopMessage[] = []; + + for (const list of messages) { + for (const it of list) { + loopMessages.push(parseBaseMessage(it)); + } + } + + return loopMessages; +} + +export function parseLLMPrompts(prompts?: string[]) { + if (!prompts?.length) { + return undefined; + } + + return prompts.length === 1 ? prompts[0] : prompts; +} diff --git a/packages/cozeloop-langchain/src/callbacks/utils.ts b/packages/cozeloop-langchain/src/callbacks/utils/model.ts similarity index 51% rename from packages/cozeloop-langchain/src/callbacks/utils.ts rename to packages/cozeloop-langchain/src/callbacks/utils/model.ts index ed6771e..6b741f4 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/model.ts @@ -1,48 +1,7 @@ -import { randomBytes } from 'node:crypto'; - +import { type LLMResult } from '@langchain/core/outputs'; import { type Serialized } from '@langchain/core/dist/load/serializable'; -export function generateUUID(): string { - return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, it => { - const v = Number(it); - - return (v ^ (randomBytes(1)[0] & (15 >> (v / 4)))).toString(16); - }); -} - -export function stringifyVal(val: unknown): string { - switch (typeof val) { - case 'number': - case 'bigint': - return `${val}`; - case 'boolean': - return val ? 'true' : 'false'; - case 'string': - case 'symbol': - return val.toString(); - case 'object': { - if (val === null) { - return ''; - } - if (val instanceof Date) { - return val.toISOString(); - } - if (val instanceof Error) { - return val.message; - } - if (Array.isArray(val)) { - return val.map(it => stringifyVal(it)).join(','); - } - return JSON.stringify(val); - } - case 'undefined': - return ''; - case 'function': - return `function@${val.name}`; - default: - return ''; - } -} +import { parseRawMessage } from './message'; export function guessModelProvider(modelName: string) { if (!modelName) { @@ -100,3 +59,52 @@ export function extractLLMAttributes( max_tokens: mixed.max_tokens as number, }; } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- skip +function parseUsage(usage: any = {}) { + const prompt_tokens = usage.promptTokens ?? usage.prompt_tokens ?? 0; + const completion_tokens = + usage.completionTokens ?? usage.completion_tokens ?? 0; + const total_tokens = usage.totalTokens ?? usage.total_tokens ?? 0; + + return { prompt_tokens, completion_tokens, total_tokens }; +} + +export function parseLLMResult(result?: LLMResult) { + if (!result) { + return undefined; + } + + const { generations, llmOutput } = result; + + if (!generations?.length || !generations[0].length) { + return undefined; + } + + const choices: unknown[] = []; + // 🌰 llmOutput { + // tokenUsage: { promptTokens: 81, completionTokens: 39, totalTokens: 120 } + // } + const usage = parseUsage(llmOutput?.tokenUsage); + + let model_name: string | undefined; + + let cnt = 0; + for (const list of generations) { + for (const it of list) { + cnt++; + model_name = model_name ?? it.generationInfo?.model_name; + choices.push({ + index: cnt, + message: parseRawMessage(it), + finish_reason: it.generationInfo?.finish_reason, + }); + } + } + + return { + model_name, + choices, + usage, + }; +} diff --git a/packages/cozeloop-langchain/tsconfig.json b/packages/cozeloop-langchain/tsconfig.json index 1f7637f..c4e85c6 100644 --- a/packages/cozeloop-langchain/tsconfig.json +++ b/packages/cozeloop-langchain/tsconfig.json @@ -9,7 +9,8 @@ "types": ["node", "vitest/globals"], "target": "es2022", "paths": { - "@cozeloop/langchain": ["./src/core/index.ts"] + "@cozeloop/langchain": ["./src/index.ts"], + "@cozeloop/langchain/*": ["./src/*"] }, "experimentalDecorators": true, "tsBuildInfoFile": "./temp/.tsbuildinfo" From 1d45e731a1af4ee5900021f6ee6174863cf6055e Mon Sep 17 00:00:00 2001 From: qihai Date: Wed, 2 Jul 2025 12:06:29 +0800 Subject: [PATCH 04/13] feat(cozeloop-langchain): multi callback exporter --- .../__tests__/__mock__/graph-agent.ts | 40 +++++++++++ .../__tests__/__mock__/index.ts | 1 + .../__tests__/batching-queue.test.ts | 2 +- .../__tests__/callback.test.ts | 44 +++++++++--- .../src/callbacks/callback-handler.ts | 58 ++++++++-------- .../cozeloop-langchain/src/callbacks/index.ts | 2 - .../src/callbacks/tree-log-processor.ts | 65 ------------------ .../src/callbacks/utils/index.ts | 2 +- .../src/callbacks/utils/model.ts | 2 +- packages/cozeloop-langchain/src/index.ts | 1 + .../src/{callbacks => otel}/batching-queue.ts | 0 .../cozeloop-exporter.ts} | 49 +++++--------- .../src/otel/cozeloop-processor.ts | 67 +++++++++++++++++++ packages/cozeloop-langchain/src/otel/index.ts | 5 ++ .../src/{callbacks => otel}/schema.ts | 6 +- packages/cozeloop-langchain/src/otel/sdk.ts | 32 +++++++++ 16 files changed, 237 insertions(+), 139 deletions(-) create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/graph-agent.ts delete mode 100644 packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts rename packages/cozeloop-langchain/src/{callbacks => otel}/batching-queue.ts (100%) rename packages/cozeloop-langchain/src/{callbacks/processor.ts => otel/cozeloop-exporter.ts} (56%) create mode 100644 packages/cozeloop-langchain/src/otel/cozeloop-processor.ts create mode 100644 packages/cozeloop-langchain/src/otel/index.ts rename packages/cozeloop-langchain/src/{callbacks => otel}/schema.ts (90%) create mode 100644 packages/cozeloop-langchain/src/otel/sdk.ts diff --git a/packages/cozeloop-langchain/__tests__/__mock__/graph-agent.ts b/packages/cozeloop-langchain/__tests__/__mock__/graph-agent.ts new file mode 100644 index 0000000..2138b38 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/graph-agent.ts @@ -0,0 +1,40 @@ +// npm install @langchain-anthropic +import { z } from 'zod'; +import { AzureChatOpenAI } from '@langchain/openai'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { tool } from '@langchain/core/tools'; + +const search = tool( + ({ query }) => { + if ( + query.toLowerCase().includes('sf') || + query.toLowerCase().includes('san francisco') + ) { + return "It's 60 degrees and foggy."; + } + return "It's 90 degrees and sunny."; + }, + { + name: 'search', + description: 'Call to surf the web.', + schema: z.object({ + query: z.string().describe('The query to use in your search.'), + }), + }, +); + +const model = new AzureChatOpenAI({ + temperature: 0, + modelName: 'gpt-4o-2024-05-13', + azureOpenAIApiInstanceName: 'azure-ins', + azureOpenAIApiDeploymentName: 'azure-dep', + azureOpenAIEndpoint: process.env.GPT_OPEN_API_BASE_URL, + azureOpenAIApiVersion: '2024-03-01-preview', + azureOpenAIApiKey: process.env.GPT_OPEN_API_KEY, + maxTokens: 1000, +}); + +export const graphAgent = createReactAgent({ + llm: model, + tools: [search], +}); diff --git a/packages/cozeloop-langchain/__tests__/__mock__/index.ts b/packages/cozeloop-langchain/__tests__/__mock__/index.ts index 97b8865..8d90630 100644 --- a/packages/cozeloop-langchain/__tests__/__mock__/index.ts +++ b/packages/cozeloop-langchain/__tests__/__mock__/index.ts @@ -1,3 +1,4 @@ export { CustomLLM } from './custom-model'; export { CustomRetriever } from './custom-retriever'; export { reactAgentExecutor } from './react-agent'; +export { graphAgent } from './graph-agent'; diff --git a/packages/cozeloop-langchain/__tests__/batching-queue.test.ts b/packages/cozeloop-langchain/__tests__/batching-queue.test.ts index ef6ad75..56056ba 100644 --- a/packages/cozeloop-langchain/__tests__/batching-queue.test.ts +++ b/packages/cozeloop-langchain/__tests__/batching-queue.test.ts @@ -1,4 +1,4 @@ -import { BatchingQueue } from '../src/callbacks/batching-queue'; +import { BatchingQueue } from '@cozeloop/langchain/otel'; describe('BatchingQueue', () => { it('🧪 should dequeue after delay', async () => { diff --git a/packages/cozeloop-langchain/__tests__/callback.test.ts b/packages/cozeloop-langchain/__tests__/callback.test.ts index ee5dc48..c7083cd 100644 --- a/packages/cozeloop-langchain/__tests__/callback.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback.test.ts @@ -4,11 +4,16 @@ import { CozeloopCallbackHandler, type CozeloopCallbackHandlerInput, } from '@cozeloop/langchain'; -import { CustomLLM, CustomRetriever, reactAgentExecutor } from './__mock__'; +import { + CustomLLM, + CustomRetriever, + reactAgentExecutor, + graphAgent, +} from './__mock__'; const makeCallback = (input?: CozeloopCallbackHandlerInput) => new CozeloopCallbackHandler({ - spanProcessor: { + spanExporter: { traceEndpoint: 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', headers: { @@ -18,7 +23,7 @@ const makeCallback = (input?: CozeloopCallbackHandlerInput) => ...input, }); -describe('Callback Test', () => { +describe('Callback with langchain', () => { it('🧪 invoke model', async () => { const prompt = ChatPromptTemplate.fromTemplate('What is 1 + {number}?'); const model = new CustomLLM({}); @@ -32,20 +37,23 @@ describe('Callback Test', () => { callbacks: [callback], }, ); - console.info(resp); - // to ensure report success + + expect(resp).toBeTruthy(); + await callback.shutdown(); }); - it.only('🧪 stream model', async () => { + it('🧪 stream model', async () => { const callback = makeCallback(); const model = new CustomLLM({}); const resp = await model.stream(['hi', '你好'], { callbacks: [callback], }); + for await (const chunk of resp) { expect(chunk).not.toBeUndefined(); } + await callback.shutdown(); }); @@ -56,7 +64,7 @@ describe('Callback Test', () => { { callbacks: [callback] }, ); - console.info(resp); + expect(resp).toBeTruthy(); await callback.shutdown(); }); @@ -69,7 +77,27 @@ describe('Callback Test', () => { callbacks: [callback], runName: '🧑‍🍳 烹饪大家', }); - console.info(resp); + expect(resp.length).toBeGreaterThan(1); + + await callback.shutdown(); + }); +}); + +describe('Callback with langgraph', () => { + it('🧪 graph agent', async () => { + const callback = makeCallback(); + const resp = await graphAgent.invoke( + { + messages: [ + { + role: 'user', + content: 'what is the weather in sf', + }, + ], + }, + { callbacks: [callback] }, + ); + expect(resp.messages.length).toBeGreaterThan(1); await callback.shutdown(); }); diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts index 11730b4..ee17ba7 100644 --- a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -1,28 +1,24 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- callback handler params */ /* eslint-disable max-params -- callback handler methods */ -import { NodeSDK as OTelNodeSDK } from '@opentelemetry/sdk-node'; import { - type Span, + trace, + context, SpanStatusCode, type Tracer, - context, - trace, + type Span, } from '@opentelemetry/api'; +import { type ChainValues } from '@langchain/core/utils/types'; import { type LLMResult } from '@langchain/core/outputs'; import { type BaseMessage } from '@langchain/core/messages'; +import { type Serialized } from '@langchain/core/load/serializable'; import { type DocumentInterface } from '@langchain/core/documents'; -import { type ChainValues } from '@langchain/core/dist/utils/types'; -import { type Serialized } from '@langchain/core/dist/load/serializable'; -import { - type AgentAction, - type AgentFinish, -} from '@langchain/core/dist/agents'; import { type BaseCallbackHandlerInput, BaseCallbackHandler, type NewTokenIndices, type HandleLLMNewTokenCallbackFields, } from '@langchain/core/callbacks/base'; +import { type AgentAction, type AgentFinish } from '@langchain/core/agents'; import { parseLLMPrompts } from './utils/message'; import { @@ -34,15 +30,14 @@ import { parseLLMResult, stringifyVal, } from './utils'; -import { TreeLogSpanProcessor } from './tree-log-processor'; -import { type CozeloopSpanProcessorOptions } from './schema'; -import { CozeloopSpanProcessor } from './processor'; import { CozeloopAttr, CozeloopSpanType } from './constants'; +import { OTelNodeSDK, type CozeloopSpanExporterOptions } from '../otel'; export interface CozeloopCallbackHandlerInput extends BaseCallbackHandlerInput { /** Weather to ignore prompt node like {@link ChatPromptTemplate} */ ignorePrompt?: boolean; - spanProcessor?: Partial; + /** Span exporter {@link options CozeloopSpanExporterOptions} */ + spanExporter?: CozeloopSpanExporterOptions; } export class CozeloopCallbackHandler @@ -55,7 +50,7 @@ export class CozeloopCallbackHandler ignorePrompt: boolean; - private readonly _otel: OTelNodeSDK; + private readonly _tracerName: string; private readonly _tracer: Tracer; @@ -68,18 +63,16 @@ export class CozeloopCallbackHandler private readonly _agentRunIdMap = new Map(); constructor(handlerInput: CozeloopCallbackHandlerInput = {}) { - const { ignorePrompt, spanProcessor = {}, ...input } = handlerInput; + const { ignorePrompt, spanExporter = {}, ...input } = handlerInput; super(input); this.ignorePrompt = ignorePrompt ?? false; - this._otel = new OTelNodeSDK({ - spanProcessors: [ - new CozeloopSpanProcessor(spanProcessor), - new TreeLogSpanProcessor(), - ], - }); - this._otel.start(); - // MUST initialize tracer after otel sdk started - this._tracer = trace.getTracer(this.name, process.env.COZELOOP_VERSION); + + const tracerName = generateUUID(); + this._tracerName = tracerName; + OTelNodeSDK.addExporter(tracerName, spanExporter); + + // MUST initialize tracer after the OTelNodeSDK started + this._tracer = trace.getTracer(tracerName, process.env.COZELOOP_VERSION); } handleText( @@ -255,7 +248,6 @@ export class CozeloopCallbackHandler tags?: string[], extraParams?: Record, ) { - console.info(output.generations); this._endSpan(runId, undefined, span => { const result = parseLLMResult(output); @@ -537,9 +529,21 @@ export class CozeloopCallbackHandler } } + /** + * Flush {@link CozeloopCallbackHandler callback} and exporter + */ + async flush() { + this._runMap.clear(); + this._llmStartMap.clear(); + await OTelNodeSDK.flushExporter(this._tracerName); + } + + /** + * Shutdown {@link CozeloopCallbackHandler callback} and remove exporter + */ async shutdown() { this._runMap.clear(); this._llmStartMap.clear(); - await this._otel.shutdown(); + await OTelNodeSDK.removeExporter(this._tracerName); } } diff --git a/packages/cozeloop-langchain/src/callbacks/index.ts b/packages/cozeloop-langchain/src/callbacks/index.ts index 81e1f86..552543d 100644 --- a/packages/cozeloop-langchain/src/callbacks/index.ts +++ b/packages/cozeloop-langchain/src/callbacks/index.ts @@ -2,5 +2,3 @@ export { CozeloopCallbackHandler, type CozeloopCallbackHandlerInput, } from './callback-handler'; - -export { CozeloopSpanProcessor } from './processor'; diff --git a/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts b/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts deleted file mode 100644 index 3b270b1..0000000 --- a/packages/cozeloop-langchain/src/callbacks/tree-log-processor.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - type ReadableSpan, - type Span, - type SpanProcessor, -} from '@opentelemetry/sdk-trace-base'; -import { type Context } from '@opentelemetry/api'; - -interface TreeSpan { - name: string; - id: string; - parent?: string; -} - -// 将 TreeSpan[] 转换成对象形式,方便检索 -function buildTree(data: TreeSpan[]): { [key: string]: TreeSpan } { - const treeMap: { [key: string]: TreeSpan } = {}; - data.forEach(item => { - treeMap[item.id] = item; - }); - return treeMap; -} - -// 递归打印树状结构 -function printTree( - tree: { [key: string]: TreeSpan }, - parentId: string | undefined, - indent = 0, -) { - const childNodes = Object.values(tree).filter( - node => node.parent === parentId, - ); - childNodes.forEach(node => { - const root = node.parent ? '-' : '🟢'; - console.log(`${' '.repeat(indent)}${root} ${node.name} [${node.id}]`); - printTree(tree, node.id, indent + 1); - }); -} - -export class TreeLogSpanProcessor implements SpanProcessor { - private _treeSpans: TreeSpan[] = []; - - onStart(span: Span, parentContext: Context): void { - // no - op - } - - onEnd(span: ReadableSpan): void { - this._treeSpans.push({ - name: span.name, - id: span.spanContext().spanId, - parent: span.parentSpanId, - }); - } - - forceFlush(): Promise { - this._treeSpans = []; - return Promise.resolve(); - } - - shutdown(): Promise { - const tree = buildTree(this._treeSpans); - printTree(tree, undefined); - this._treeSpans = []; - return Promise.resolve(); - } -} diff --git a/packages/cozeloop-langchain/src/callbacks/utils/index.ts b/packages/cozeloop-langchain/src/callbacks/utils/index.ts index 21c29b7..b7fb2c5 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/index.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/index.ts @@ -1,6 +1,6 @@ export { guessChainInput, guessChainOutput } from './chain'; export { generateUUID, stringifyVal } from './common'; -export { parseBaseMessages } from './message'; +export { parseBaseMessages, parseRawMessage } from './message'; export { extractLLMAttributes, guessModelProvider, diff --git a/packages/cozeloop-langchain/src/callbacks/utils/model.ts b/packages/cozeloop-langchain/src/callbacks/utils/model.ts index 6b741f4..77055e7 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/model.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/model.ts @@ -1,5 +1,5 @@ import { type LLMResult } from '@langchain/core/outputs'; -import { type Serialized } from '@langchain/core/dist/load/serializable'; +import { type Serialized } from '@langchain/core/load/serializable'; import { parseRawMessage } from './message'; diff --git a/packages/cozeloop-langchain/src/index.ts b/packages/cozeloop-langchain/src/index.ts index 31e64fc..b53916d 100644 --- a/packages/cozeloop-langchain/src/index.ts +++ b/packages/cozeloop-langchain/src/index.ts @@ -1,2 +1,3 @@ export { CozeloopCallbackHandler } from './callbacks'; export type { CozeloopCallbackHandlerInput } from './callbacks'; +export type { CozeloopSpanExporterOptions } from './otel'; diff --git a/packages/cozeloop-langchain/src/callbacks/batching-queue.ts b/packages/cozeloop-langchain/src/otel/batching-queue.ts similarity index 100% rename from packages/cozeloop-langchain/src/callbacks/batching-queue.ts rename to packages/cozeloop-langchain/src/otel/batching-queue.ts diff --git a/packages/cozeloop-langchain/src/callbacks/processor.ts b/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts similarity index 56% rename from packages/cozeloop-langchain/src/callbacks/processor.ts rename to packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts index 465aabc..0bab9d2 100644 --- a/packages/cozeloop-langchain/src/callbacks/processor.ts +++ b/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts @@ -1,25 +1,20 @@ -import { - type ReadableSpan, - type Span, - type SpanProcessor, -} from '@opentelemetry/sdk-trace-base'; +import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { globalErrorHandler } from '@opentelemetry/core'; -import { type Context } from '@opentelemetry/api'; import { - type CozeloopSpanProcessorOptions, - CozeloopSpanProcessorOptionsSchema, + type CozeloopSpanExporterOptions, + CozeloopSpanExporterOptionsSchema, } from './schema'; import { BatchingQueue } from './batching-queue'; -export class CozeloopSpanProcessor implements SpanProcessor { +export class CozeloopSpanExporter { private _exporter: OTLPTraceExporter; private _queue: BatchingQueue; private static WORKSPACE_ID_HEADER = 'cozeloop-workspace-id'; - constructor(options: Partial) { + constructor(options: CozeloopSpanExporterOptions) { const { token, workspaceId, @@ -27,20 +22,22 @@ export class CozeloopSpanProcessor implements SpanProcessor { traceEndpoint, batchSize, scheduleDelay, - } = CozeloopSpanProcessorOptionsSchema.parse(options); - this._queue = new BatchingQueue( - batchSize, - scheduleDelay, - spans => this._onExport(spans), - ); + } = CozeloopSpanExporterOptionsSchema.parse(options); + this._exporter = new OTLPTraceExporter({ url: traceEndpoint, headers: { Authorization: `Bearer ${token}`, - [CozeloopSpanProcessor.WORKSPACE_ID_HEADER]: workspaceId, + [CozeloopSpanExporter.WORKSPACE_ID_HEADER]: workspaceId, ...headers, }, }); + + this._queue = new BatchingQueue( + batchSize, + scheduleDelay, + spans => this._onExport(spans), + ); } private _onExport(spans: ReadableSpan[]) { @@ -53,27 +50,17 @@ export class CozeloopSpanProcessor implements SpanProcessor { }); } - onStart(span: Span, parentContext: Context): void { - // no op - // console.info( - // `[Start] ${span.name} ${span.spanContext().spanId} parent = ${span.parentSpanId || ''}`, - // ); - } - - onEnd(span: ReadableSpan): void { + enqueue(span: ReadableSpan) { this._queue.enqueue(span); - // console.info( - // `[Ennnd] ${span.name} ${span.spanContext().spanId}, runId=${span.attributes['langchain-run-id']}`, - // ); } - async forceFlush() { - await this._queue.destroy(); + async flush() { + await this._queue.flush(); await this._exporter.forceFlush(); } async shutdown() { - await this._queue.destroy(); + await this.flush(); await this._exporter.shutdown(); } } diff --git a/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts b/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts new file mode 100644 index 0000000..1995457 --- /dev/null +++ b/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts @@ -0,0 +1,67 @@ +import { + type ReadableSpan, + type Span, + type SpanProcessor, +} from '@opentelemetry/sdk-trace-base'; +import { type Context } from '@opentelemetry/api'; + +import { type CozeloopSpanExporterOptions } from './schema'; +import { CozeloopSpanExporter } from './cozeloop-exporter'; + +export class CozeloopSpanProcessor implements SpanProcessor { + private _exporters = new Map(); + + onStart(span: Span, parentContext: Context): void { + // no-op + // console.info( + // `[Start] ${span.name} ${span.spanContext().spanId} parent = ${span.parentSpanId || ''}`, + // ); + } + + onEnd(span: ReadableSpan): void { + // enqueue spans filter by name + const exporter = this._exporters.get(span.instrumentationLibrary.name); + exporter?.enqueue(span); + // console.info( + // `[Ennnd] ${span.name} ${span.spanContext().spanId}, runId=${span.attributes['langchain-run-id']}`, + // ); + } + + addExporter(name: string, options: CozeloopSpanExporterOptions) { + const exporter = new CozeloopSpanExporter(options); + + this._exporters.set(name, exporter); + + return exporter; + } + + async flushExporter(name: string) { + const exporter = this._exporters.get(name); + + if (!exporter) { + return; + } + + await exporter.flush(); + } + + async removeExporter(name: string) { + await this.flushExporter(name); + + this._exporters.delete(name); + } + + async forceFlush() { + const exporters = Array.from(this._exporters.values()); + + await Promise.allSettled(exporters.map(it => it.flush())); + } + + async shutdown() { + const exporters = Array.from(this._exporters.values()); + + await Promise.allSettled(exporters.map(it => it.shutdown())); + + this._exporters.clear(); + } +} diff --git a/packages/cozeloop-langchain/src/otel/index.ts b/packages/cozeloop-langchain/src/otel/index.ts new file mode 100644 index 0000000..c7a5d73 --- /dev/null +++ b/packages/cozeloop-langchain/src/otel/index.ts @@ -0,0 +1,5 @@ +export { OTelNodeSDK } from './sdk'; +export { CozeloopSpanProcessor } from './cozeloop-processor'; +export { CozeloopSpanExporter } from './cozeloop-exporter'; +export { BatchingQueue } from './batching-queue'; +export type { CozeloopSpanExporterOptions } from './schema'; diff --git a/packages/cozeloop-langchain/src/callbacks/schema.ts b/packages/cozeloop-langchain/src/otel/schema.ts similarity index 90% rename from packages/cozeloop-langchain/src/callbacks/schema.ts rename to packages/cozeloop-langchain/src/otel/schema.ts index d63ee98..c95c0ac 100644 --- a/packages/cozeloop-langchain/src/callbacks/schema.ts +++ b/packages/cozeloop-langchain/src/otel/schema.ts @@ -7,7 +7,7 @@ function formatPropertyUnprovidedError(propName: string, envKey: string) { const DEFAULT_BATCH_SIZE = 100; const DEFAULT_SCHEDULE_DELAY = 30_000; -export const CozeloopSpanProcessorOptionsSchema = z.object({ +export const CozeloopSpanExporterOptionsSchema = z.object({ /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ workspaceId: z .string() @@ -43,6 +43,6 @@ export const CozeloopSpanProcessorOptionsSchema = z.object({ scheduleDelay: z.number().gt(0).prefault(DEFAULT_SCHEDULE_DELAY), }); -export type CozeloopSpanProcessorOptions = z.infer< - typeof CozeloopSpanProcessorOptionsSchema +export type CozeloopSpanExporterOptions = Partial< + z.infer >; diff --git a/packages/cozeloop-langchain/src/otel/sdk.ts b/packages/cozeloop-langchain/src/otel/sdk.ts new file mode 100644 index 0000000..656e777 --- /dev/null +++ b/packages/cozeloop-langchain/src/otel/sdk.ts @@ -0,0 +1,32 @@ +import { NodeSDK } from '@opentelemetry/sdk-node'; + +import { type CozeloopSpanExporterOptions } from './schema'; +import { CozeloopSpanProcessor } from './cozeloop-processor'; + +export const OTelNodeSDK = (() => { + const cozeloopProcessor = new CozeloopSpanProcessor(); + // let started = false; + const sdk = new NodeSDK({ + spanProcessors: [cozeloopProcessor], + }); + sdk.start(); + + return { + addExporter: (name: string, options: CozeloopSpanExporterOptions = {}) => { + cozeloopProcessor.addExporter(name, options); + }, + removeExporter: async (name: string) => { + await cozeloopProcessor.removeExporter(name); + }, + flushExporter: async (name: string) => { + await cozeloopProcessor.flushExporter(name); + }, + forceFlush: async () => { + await cozeloopProcessor.forceFlush(); + }, + shutdown: async () => { + await cozeloopProcessor.shutdown(); + await sdk.shutdown(); + }, + }; +})(); From 74506cf7cecd82dfcc95b3b96a0f3e69df091cf3 Mon Sep 17 00:00:00 2001 From: qihai Date: Wed, 2 Jul 2025 15:20:01 +0800 Subject: [PATCH 05/13] feat(cozeloop-langchain): arrange ut --- .../callback-handler.test.ts} | 2 +- .../__tests__/callback/utils/chain.test.ts | 65 ++++++++++++ .../utils}/common.test.ts | 0 .../__tests__/callback/utils/message.test.ts | 98 +++++++++++++++++++ .../utils}/model.test.ts | 3 +- .../{ => otel}/batching-queue.test.ts | 0 .../__tests__/otel/processor.test.ts | 88 +++++++++++++++++ .../src/callbacks/utils/chain.ts | 4 +- 8 files changed, 255 insertions(+), 5 deletions(-) rename packages/cozeloop-langchain/__tests__/{callback.test.ts => callback/callback-handler.test.ts} (99%) create mode 100644 packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts rename packages/cozeloop-langchain/__tests__/{callback-utils => callback/utils}/common.test.ts (100%) create mode 100644 packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts rename packages/cozeloop-langchain/__tests__/{callback-utils => callback/utils}/model.test.ts (96%) rename packages/cozeloop-langchain/__tests__/{ => otel}/batching-queue.test.ts (100%) create mode 100644 packages/cozeloop-langchain/__tests__/otel/processor.test.ts diff --git a/packages/cozeloop-langchain/__tests__/callback.test.ts b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts similarity index 99% rename from packages/cozeloop-langchain/__tests__/callback.test.ts rename to packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts index c7083cd..df1693f 100644 --- a/packages/cozeloop-langchain/__tests__/callback.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts @@ -9,7 +9,7 @@ import { CustomRetriever, reactAgentExecutor, graphAgent, -} from './__mock__'; +} from '../__mock__'; const makeCallback = (input?: CozeloopCallbackHandlerInput) => new CozeloopCallbackHandler({ diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts new file mode 100644 index 0000000..9bbe309 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts @@ -0,0 +1,65 @@ +import { + guessChainInput, + guessChainOutput, +} from '@cozeloop/langchain/callbacks/utils'; + +describe('guessChainInput', () => { + it('🧪 should return undefined for undefined input', () => { + expect(guessChainInput(undefined)).toBeUndefined(); + }); + + it('🧪 should return the value of the input property if it exists', () => { + const input = { input: 'Hello, world!' }; + expect(guessChainInput(input)).toBe(input.input); + }); + + it('🧪 should return the value of the inputs property if it exists', () => { + const input = { inputs: 'Hello, world!' }; + expect(guessChainInput(input)).toBe(input.inputs); + }); + + it('🧪 should return the value of the question property if it exists', () => { + const input = { question: 'Hello, world!' }; + expect(guessChainInput(input)).toBe(input.question); + }); +}); + +describe('guessChainOutput', () => { + it('🧪 should return undefined for undefined input', () => { + expect(guessChainOutput(undefined)).toBeUndefined(); + }); + + it('🧪 should return the value of the text property if it exists', () => { + const output = { text: 'Hello, world!' }; + expect(guessChainOutput(output)).toBe(output.text); + }); + + it('🧪 should return the value of the answer property if it exists', () => { + const output = { answer: 'Hello, world!' }; + expect(guessChainOutput(output)).toBe(output.answer); + }); + + it('🧪 should return the value of the output property if it exists', () => { + const output = { output: 'Hello, world!' }; + expect(guessChainOutput(output)).toBe(output.output); + }); + + it('🧪 should return the value of the result property if it exists', () => { + const output = { result: 'Hello, world!' }; + expect(guessChainOutput(output)).toBe(output.result); + }); + + it('🧪 should return the input object if none of the expected properties exist', () => { + const output = { someOtherProperty: 'Hello, world!' }; + expect(guessChainOutput(output)).toEqual(output); + }); + + it('🧪 should recursively call guessChainOutput if the returnValues property exists', () => { + const output = { + returnValues: { + text: 'Hello, world!', + }, + }; + expect(guessChainOutput(output)).toBe(output.returnValues.text); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts similarity index 100% rename from packages/cozeloop-langchain/__tests__/callback-utils/common.test.ts rename to packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts new file mode 100644 index 0000000..f4f48af --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts @@ -0,0 +1,98 @@ +import { + AIMessage, + HumanMessage, + type MessageContentComplex, +} from '@langchain/core/messages'; +import { + parseRawMessage, + parseBaseMessage, + parseBaseMessages, + parseLLMPrompts, +} from '@cozeloop/langchain/callbacks/utils/message'; + +describe('parseRawMessage', () => { + it('🧪 should return undefined for undefined input', () => { + expect(parseRawMessage(undefined)).toBeUndefined(); + }); + + it('🧪 should return the input string for string input', () => { + const inputString = 'Hello, world!'; + expect(parseRawMessage(inputString)).toBe(inputString); + }); + + it('🧪 should parse BaseMessage correctly', () => { + const baseMessage = new HumanMessage('Hello, world!'); + const parsedMessage = parseRawMessage(baseMessage); + expect(parsedMessage).toEqual({ + role: 'user', + content: 'Hello, world!', + parts: undefined, + tool_calls: undefined, + }); + }); +}); + +describe('parseBaseMessage', () => { + it('🧪 should parse BaseMessage correctly', () => { + const complexContent: MessageContentComplex = { + type: 'image_url', + image_url: { + name: 'image.jpg', + url: 'https://example.com/image.jpg', + }, + }; + const baseMessage = new AIMessage({ content: [complexContent] }); + const parsedMessage = parseBaseMessage(baseMessage); + expect(parsedMessage).toEqual({ + role: 'assistant', + content: undefined, + parts: [ + { + type: 'image_url', + image_url: { + name: 'image.jpg', + url: 'https://example.com/image.jpg', + detail: 'https://example.com/image.jpg', + }, + }, + ], + tool_calls: undefined, + }); + }); +}); + +describe('parseBaseMessages', () => { + it('🧪 should parse an array of BaseMessage correctly', () => { + const baseMessages = [ + new HumanMessage('Hello, world!'), + new AIMessage('Hello, universe!'), + ]; + const parsedMessages = parseBaseMessages([baseMessages]); + expect(parsedMessages).toEqual([ + { + role: 'user', + content: 'Hello, world!', + parts: undefined, + tool_calls: undefined, + }, + { + role: 'assistant', + content: 'Hello, universe!', + parts: undefined, + tool_calls: undefined, + }, + ]); + }); +}); + +describe('parseLLMPrompts', () => { + it('🧪 should parse an array of strings correctly', () => { + const prompts = ['Hello, world!', 'Hello, universe!']; + expect(parseLLMPrompts(prompts)).toEqual(prompts); + }); + + it('🧪 should return the single string for an array with one element', () => { + const prompt = 'Hello, world!'; + expect(parseLLMPrompts([prompt])).toBe(prompt); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts similarity index 96% rename from packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts rename to packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts index f2d9b9e..bc14f17 100644 --- a/packages/cozeloop-langchain/__tests__/callback-utils/model.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts @@ -1,5 +1,4 @@ -import { type Serialized } from '@langchain/core/dist/load/serializable'; - +import { type Serialized } from '@langchain/core/load/serializable'; import { guessModelProvider, extractLLMAttributes, diff --git a/packages/cozeloop-langchain/__tests__/batching-queue.test.ts b/packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts similarity index 100% rename from packages/cozeloop-langchain/__tests__/batching-queue.test.ts rename to packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts diff --git a/packages/cozeloop-langchain/__tests__/otel/processor.test.ts b/packages/cozeloop-langchain/__tests__/otel/processor.test.ts new file mode 100644 index 0000000..079e00a --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/otel/processor.test.ts @@ -0,0 +1,88 @@ +import { + CozeloopSpanExporter, + CozeloopSpanProcessor, + type CozeloopSpanExporterOptions, +} from '@cozeloop/langchain/otel'; + +describe('CozeloopSpanProcessor', () => { + let processor: CozeloopSpanProcessor; + let exporterOptions: CozeloopSpanExporterOptions; + + beforeEach(() => { + processor = new CozeloopSpanProcessor(); + exporterOptions = { + traceEndpoint: 'https://example.com', + }; + }); + + describe('addExporter', () => { + it('🧪 should add a new exporter', () => { + const exporter = processor.addExporter('test', exporterOptions); + expect(exporter).toBeInstanceOf(CozeloopSpanExporter); + // @ts-expect-error -- skip private + expect(processor._exporters.get('test')).toBeTruthy(); + }); + }); + + describe('flushExporter', () => { + it('🧪 should flush the corresponding exporter', async () => { + const exporter = processor.addExporter('test', exporterOptions); + const flushSpy = vi.spyOn(exporter, 'flush'); + + await processor.flushExporter('test'); + expect(flushSpy).toHaveBeenCalled(); + }); + + it('🧪 should do nothing if the exporter does not exist', async () => { + await expect( + processor.flushExporter('nonexistent'), + ).resolves.not.toThrow(); + }); + }); + + describe('removeExporter', () => { + it('🧪 should flush and remove the corresponding exporter', async () => { + const exporter = processor.addExporter('test', exporterOptions); + const flushSpy = vi.spyOn(exporter, 'flush'); + + await processor.removeExporter('test'); + expect(flushSpy).toHaveBeenCalled(); + // @ts-expect-error -- skip private + expect(processor._exporters.has('test')).toBe(false); + }); + + it('🧪 should do nothing if the exporter does not exist', async () => { + await expect( + processor.removeExporter('nonexistent'), + ).resolves.not.toThrow(); + }); + }); + + describe('forceFlush', () => { + it('🧪 should flush all exporters', async () => { + const exporter1 = processor.addExporter('test1', exporterOptions); + const exporter2 = processor.addExporter('test2', exporterOptions); + const flushSpy1 = vi.spyOn(exporter1, 'flush'); + const flushSpy2 = vi.spyOn(exporter2, 'flush'); + + await processor.forceFlush(); + expect(flushSpy1).toHaveBeenCalled(); + expect(flushSpy2).toHaveBeenCalled(); + }); + }); + + describe('shutdown', () => { + it('🧪 should shut down all exporters and clear the exporters map', async () => { + const exporter1 = processor.addExporter('test1', exporterOptions); + const exporter2 = processor.addExporter('test2', exporterOptions); + const shutdownSpy1 = vi.spyOn(exporter1, 'shutdown'); + const shutdownSpy2 = vi.spyOn(exporter2, 'shutdown'); + + await processor.shutdown(); + expect(shutdownSpy1).toHaveBeenCalled(); + expect(shutdownSpy2).toHaveBeenCalled(); + // @ts-expect-error -- skip private + expect(processor._exporters.size).toBe(0); + }); + }); +}); diff --git a/packages/cozeloop-langchain/src/callbacks/utils/chain.ts b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts index 6943cdd..3365066 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/chain.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts @@ -1,6 +1,6 @@ import { type ChainValues } from '@langchain/core/utils/types'; -export function guessChainInput(inputs: ChainValues) { +export function guessChainInput(inputs?: ChainValues) { if (!inputs) { return undefined; } @@ -8,7 +8,7 @@ export function guessChainInput(inputs: ChainValues) { return inputs.input || inputs.inputs || inputs.question || inputs; } -export function guessChainOutput(outputs: ChainValues) { +export function guessChainOutput(outputs?: ChainValues) { if (!outputs) { return undefined; } From b11febdef19b7c317fcbac64072a373a5613736d Mon Sep 17 00:00:00 2001 From: qihai Date: Wed, 2 Jul 2025 16:14:34 +0800 Subject: [PATCH 06/13] feat(cozeloop-langchain): readme changelog --- packages/cozeloop-langchain/CHANGELOG.md | 5 + packages/cozeloop-langchain/LICENSE | 21 ++++ packages/cozeloop-langchain/README.md | 112 ++++++++++++++++++ .../callback/callback-handler.test.ts | 10 +- packages/cozeloop-langchain/package.json | 3 +- packages/cozeloop-langchain/src/global.d.ts | 6 +- .../cozeloop-langchain/src/otel/schema.ts | 12 +- packages/cozeloop-langchain/vitest.config.ts | 5 - 8 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 packages/cozeloop-langchain/CHANGELOG.md create mode 100644 packages/cozeloop-langchain/LICENSE create mode 100644 packages/cozeloop-langchain/README.md diff --git a/packages/cozeloop-langchain/CHANGELOG.md b/packages/cozeloop-langchain/CHANGELOG.md new file mode 100644 index 0000000..34dfcdf --- /dev/null +++ b/packages/cozeloop-langchain/CHANGELOG.md @@ -0,0 +1,5 @@ +# 🕗 Change Log - @cozeloop/langchain + +## 0.0.1 +🌱 Initial version + diff --git a/packages/cozeloop-langchain/LICENSE b/packages/cozeloop-langchain/LICENSE new file mode 100644 index 0000000..8badb49 --- /dev/null +++ b/packages/cozeloop-langchain/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Bytedance Ltd. and/or its affiliates + +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/packages/cozeloop-langchain/README.md b/packages/cozeloop-langchain/README.md new file mode 100644 index 0000000..c0aa6a8 --- /dev/null +++ b/packages/cozeloop-langchain/README.md @@ -0,0 +1,112 @@ +# 🧭 CozeLoop LangChain Integration + +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +Integrate Langchain with Cozeloop via `@cozeloop/langchain`, supports: +* `CozeloopCallbackHandler`: report trace to Cozeloop + +## Quick Start + +### 1. Installation + +```sh +npm install @cozeloop/langchain +# or +pnpm install @cozeloop/langchain +``` + +### 2. Basic Usage + +1. Environment variables + +The following variables are optional, and will be used if values provided. + +|Variable|Comment|Example| +|----|----|------| +|COZELOOP_WORKSPACE_ID|Cozeloop workspace id, used to identify the workspace to which resource such as trace belongs|'7487806534651887643'| +|COZELOOP_API_TOKEN|Cozeloop api token|'pat_xxxx'| +|COZELOOP_OTLP_ENDPOINT|Trace endpoint|'https://api.coze.cn/v1/loop/opentelemetry/v1/trace'| + + +2. `CozeloopCallbackHandler` + +Callbacks can be used with LangChain and LangGraph. + +Since the traces exporting is asynchronous, it can be interrupted by the termination of Node.js process. + +(1) When you run a file in command line, call `await callback.flush();` to ensure all traces exported. + +(2) If you are running a server-side application, there is no need to flush, but it's recommended to call `await callback.shutdown();` to clean resource. + +* Initialize + +```typescript +import { CozeloopCallbackHandler } from '@cozeloop/langchain'; + +// initialize callback +const callback = new CozeloopCallbackHandler({ + // Common callback params + // ignoreAgent: false, + // ignoreChain: false, + // ignoreCustomEvent: false, + // ignoreLLM: false, + // ignoreRetriever: false, + // ignorePrompt: false, + // Span exporter + spanExporter: { + workspaceId: 'xxx', + token: 'pat_xxx', + traceEndpoint: 'https://api.coze.cn/v1/loop/opentelemetry/v1/traces', + }, +}); +``` + +* With LangChain +```typescript +import { CozeloopCallbackHandler } from '@cozeloop/langchain'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; + +// Chain +const prompt = ChatPromptTemplate.fromTemplate('What is 1 + {number}?'); +const model = new CustomLLM({}); +const chain = prompt.pipe(model); +const callback = new CozeloopCallbackHandler({ /** options */}); + +const resp = await chain.invoke( + { number: 1 }, + { + runName: 'SuperChain', + callbacks: [callback], // set callback + }, +); + +// call `flush` to ensure trace exported in script run +await callback.flush(); +``` + +* With LangGraph +```typescript +import { CozeloopCallbackHandler } from '@cozeloop/langchain'; + +import { createReactAgent } from '@langchain/langgraph/prebuilt'; + +const agent = createReactAgent({ + llm: model, + tools: [tool1, tool2], +}); + +const resp = await graphAgent.invoke( + { + messages: [ + { + role: 'user', + content: 'xxx', + }, + ], + }, + { callbacks: [callback] }, // set callback +); + +// call `flush` to ensure trace exported in script run +await callback.flush(); +``` diff --git a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts index df1693f..4039334 100644 --- a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts @@ -40,7 +40,7 @@ describe('Callback with langchain', () => { expect(resp).toBeTruthy(); - await callback.shutdown(); + await callback.flush(); }); it('🧪 stream model', async () => { @@ -54,7 +54,7 @@ describe('Callback with langchain', () => { expect(chunk).not.toBeUndefined(); } - await callback.shutdown(); + await callback.flush(); }); it('🧪 react agent', async () => { @@ -66,7 +66,7 @@ describe('Callback with langchain', () => { expect(resp).toBeTruthy(); - await callback.shutdown(); + await callback.flush(); }); it('🧪 retriever', async () => { @@ -79,7 +79,7 @@ describe('Callback with langchain', () => { }); expect(resp.length).toBeGreaterThan(1); - await callback.shutdown(); + await callback.flush(); }); }); @@ -99,6 +99,6 @@ describe('Callback with langgraph', () => { ); expect(resp.messages.length).toBeGreaterThan(1); - await callback.shutdown(); + await callback.flush(); }); }); diff --git a/packages/cozeloop-langchain/package.json b/packages/cozeloop-langchain/package.json index 80637d0..c84cfaf 100644 --- a/packages/cozeloop-langchain/package.json +++ b/packages/cozeloop-langchain/package.json @@ -32,8 +32,7 @@ "dist", "LICENSE", "README.md", - "CHANGELOG.md", - "README.zh-CN.md" + "CHANGELOG.md" ], "scripts": { "build": "tsup", diff --git a/packages/cozeloop-langchain/src/global.d.ts b/packages/cozeloop-langchain/src/global.d.ts index 0d6dc3a..898b461 100644 --- a/packages/cozeloop-langchain/src/global.d.ts +++ b/packages/cozeloop-langchain/src/global.d.ts @@ -6,8 +6,12 @@ declare module 'process' { interface ProcessEnv { /** SDK Version, which is injected via vitest or tsup from package.json */ COZELOOP_VERSION: string; + /** API token */ COZELOOP_API_TOKEN?: string; - OTEL_EXPORTER_OTLP_ENDPOINT?: string; + /** Workspace id */ + COZELOOP_WORKSPACE_ID?: string; + /** OTLP Endpoint, @default 'https://api.coze.cn/v1/loop/opentelemetry/v1/traces' */ + COZELOOP_OTLP_ENDPOINT?: string; } } } diff --git a/packages/cozeloop-langchain/src/otel/schema.ts b/packages/cozeloop-langchain/src/otel/schema.ts index c95c0ac..2daab57 100644 --- a/packages/cozeloop-langchain/src/otel/schema.ts +++ b/packages/cozeloop-langchain/src/otel/schema.ts @@ -6,6 +6,8 @@ function formatPropertyUnprovidedError(propName: string, envKey: string) { const DEFAULT_BATCH_SIZE = 100; const DEFAULT_SCHEDULE_DELAY = 30_000; +const DEFAULT_TRACE_ENDPOINT = + 'https://api.coze.cn/v1/loop/opentelemetry/v1/traces'; export const CozeloopSpanExporterOptionsSchema = z.object({ /** Workspace ID, use process.env.COZELOOP_WORKSPACE_ID when unprovided */ @@ -18,16 +20,10 @@ export const CozeloopSpanExporterOptionsSchema = z.object({ 'COZELOOP_WORKSPACE_ID', ), }), - /** Endpoint to export traces, use process.env.OTEL_EXPORTER_OTLP_ENDPOINT when unprovided */ + /** Endpoint to export traces, use process.env.COZELOOP_OTLP_ENDPOINT when unprovided */ traceEndpoint: z .string() - .prefault(process.env.OTEL_EXPORTER_OTLP_ENDPOINT || '') - .refine(val => Boolean(val), { - message: formatPropertyUnprovidedError( - 'traceEndpoint', - 'OTEL_EXPORTER_OTLP_ENDPOINT', - ), - }), + .prefault(process.env.COZELOOP_OTLP_ENDPOINT || DEFAULT_TRACE_ENDPOINT), /** CozeLoop API token, use process.env.COZELOOP_API_TOKEN when unprovided */ token: z .string() diff --git a/packages/cozeloop-langchain/vitest.config.ts b/packages/cozeloop-langchain/vitest.config.ts index dc54aee..78e73bc 100644 --- a/packages/cozeloop-langchain/vitest.config.ts +++ b/packages/cozeloop-langchain/vitest.config.ts @@ -13,11 +13,6 @@ export default defineConfig({ }, env: { COZELOOP_VERSION: packageJson.version, - COZELOOP_WORKSPACE_ID: '7480080243929694252', - // OTEL_EXPORTER_OTLP_ENDPOINT: - // 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', - COZELOOP_API_TOKEN: - 'pat_zaguQ3FIZNicL5GZUgoSgLeXRg7dieSxpSWaQ4DaE3YKMr30dwzNz829Qqg5qJl0', }, }, }); From a08997c334945fb7189e822ca4a4efcf76855b5d Mon Sep 17 00:00:00 2001 From: qihai Date: Thu, 3 Jul 2025 17:00:26 +0800 Subject: [PATCH 07/13] feat(cozeloop-langchain): propagation --- packages/cozeloop-langchain/README.md | 12 ++- .../callback/callback-handler.test.ts | 15 ++-- .../src/callbacks/callback-handler.ts | 55 ++++++++++-- packages/cozeloop-langchain/src/otel/index.ts | 4 + .../src/otel/propagation.ts | 88 +++++++++++++++++++ .../cozeloop-langchain/src/otel/schema.ts | 5 +- 6 files changed, 157 insertions(+), 22 deletions(-) create mode 100644 packages/cozeloop-langchain/src/otel/propagation.ts diff --git a/packages/cozeloop-langchain/README.md b/packages/cozeloop-langchain/README.md index c0aa6a8..a4ecca4 100644 --- a/packages/cozeloop-langchain/README.md +++ b/packages/cozeloop-langchain/README.md @@ -24,7 +24,7 @@ The following variables are optional, and will be used if values provided. |Variable|Comment|Example| |----|----|------| |COZELOOP_WORKSPACE_ID|Cozeloop workspace id, used to identify the workspace to which resource such as trace belongs|'7487806534651887643'| -|COZELOOP_API_TOKEN|Cozeloop api token|'pat_xxxx'| +|COZELOOP_API_TOKEN|Cozeloop api token, see [authentication-for-sdk](https://loop.coze.cn/open/docs/cozeloop/authentication-for-sdk) |'pat_xxxx'| |COZELOOP_OTLP_ENDPOINT|Trace endpoint|'https://api.coze.cn/v1/loop/opentelemetry/v1/trace'| @@ -52,13 +52,21 @@ const callback = new CozeloopCallbackHandler({ // ignoreLLM: false, // ignoreRetriever: false, // ignorePrompt: false, - // Span exporter + /** Span exporter */ spanExporter: { workspaceId: 'xxx', token: 'pat_xxx', traceEndpoint: 'https://api.coze.cn/v1/loop/opentelemetry/v1/traces', }, + /** Propagate with upstream services */ + // propagationHeaders: { + // tracestate: '', + // traceparent: '00-b3691bfe8af1415029177821d4114cef-ddd0307891d51ce3-01', + // }, }); + +// use to propagate with downstream services +// callback.w3cPropagationHeaders ``` * With LangChain diff --git a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts index 4039334..a6fb7a4 100644 --- a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts @@ -12,16 +12,7 @@ import { } from '../__mock__'; const makeCallback = (input?: CozeloopCallbackHandlerInput) => - new CozeloopCallbackHandler({ - spanExporter: { - traceEndpoint: - 'https://api-bot-boe.bytedance.net/v1/loop/opentelemetry/v1/traces', - headers: { - 'x-tt-env': 'boe_otel_ingest_trace', - }, - }, - ...input, - }); + new CozeloopCallbackHandler({ ...input }); describe('Callback with langchain', () => { it('🧪 invoke model', async () => { @@ -39,6 +30,10 @@ describe('Callback with langchain', () => { ); expect(resp).toBeTruthy(); + expect(callback.w3cPropagationHeaders?.traceparent).not.toBeUndefined(); + expect( + callback.w3cPropagationHeaders?.['X-Cozeloop-Traceparent'], + ).not.toBeUndefined(); await callback.flush(); }); diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts index ee17ba7..e4dbffa 100644 --- a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -31,13 +31,31 @@ import { stringifyVal, } from './utils'; import { CozeloopAttr, CozeloopSpanType } from './constants'; -import { OTelNodeSDK, type CozeloopSpanExporterOptions } from '../otel'; +import { + extractPropagationHeaders, + injectPropagationHeaders, + OTelNodeSDK, + type CozeloopSpanExporterOptions, +} from '../otel'; export interface CozeloopCallbackHandlerInput extends BaseCallbackHandlerInput { /** Weather to ignore prompt node like {@link ChatPromptTemplate} */ ignorePrompt?: boolean; /** Span exporter {@link options CozeloopSpanExporterOptions} */ spanExporter?: CozeloopSpanExporterOptions; + /** + * Propagate with upstream service, see {@link https://opentelemetry.io/docs/concepts/context-propagation/ context propagation} + * + * Priority: + * * X-Cozeloop-Traceparent > traceparent + * * X-Cozeloop-Tracestate > tracestate + */ + propagationHeaders?: { + 'X-Cozeloop-Traceparent'?: string; + traceparent?: string; + 'X-Cozeloop-Tracestate'?: string; + tracestate?: string; + }; } export class CozeloopCallbackHandler @@ -46,11 +64,14 @@ export class CozeloopCallbackHandler { name = 'cozeloop-langchain-callback'; - _awaitHandler?: boolean; + private readonly _tracerName: string; - ignorePrompt: boolean; + private readonly _ignorePrompt: boolean; - private readonly _tracerName: string; + private readonly _propagationHeaders: CozeloopCallbackHandlerInput['propagationHeaders']; + + private _w3cPropagationHeaders: CozeloopCallbackHandlerInput['propagationHeaders'] = + {}; private readonly _tracer: Tracer; @@ -62,10 +83,21 @@ export class CozeloopCallbackHandler private readonly _agentRunIdMap = new Map(); + /** W3C compatible propagation headers for propagating with downstream services */ + get w3cPropagationHeaders() { + return this._w3cPropagationHeaders; + } + constructor(handlerInput: CozeloopCallbackHandlerInput = {}) { - const { ignorePrompt, spanExporter = {}, ...input } = handlerInput; + const { + ignorePrompt, + propagationHeaders, + spanExporter = {}, + ...input + } = handlerInput; super(input); - this.ignorePrompt = ignorePrompt ?? false; + this._ignorePrompt = ignorePrompt ?? false; + this._propagationHeaders = propagationHeaders; const tracerName = generateUUID(); this._tracerName = tracerName; @@ -447,7 +479,7 @@ export class CozeloopCallbackHandler tags?: string[], kwargs?: { inputs?: Record }, ) { - if (this.ignorePrompt) { + if (this._ignorePrompt) { return; } this._promptChain.delete(runId); @@ -465,7 +497,7 @@ export class CozeloopCallbackHandler tags?: string[], kwargs?: { inputs?: Record }, ) { - if (this.ignorePrompt) { + if (this._ignorePrompt) { return; } this._promptChain.delete(runId); @@ -488,10 +520,15 @@ export class CozeloopCallbackHandler const parentSpan = parentRunId ? this._runMap.get(parentRunId) : undefined; const currentContext = parentSpan ? trace.setSpan(context.active(), parentSpan) - : context.active(); + : this._propagationHeaders + ? extractPropagationHeaders(context.active(), this._propagationHeaders) + : context.active(); context.with(currentContext, () => { + injectPropagationHeaders(currentContext, this._w3cPropagationHeaders); + console.info(this._w3cPropagationHeaders); const span = this._tracer.startSpan(name); + // span.setAttribute(CozeloopAttr.WORKSPACE_ID, this._workspaceId || ''); this._runMap.set(runId, span); span.setAttribute('langchain-run-id', runId); diff --git a/packages/cozeloop-langchain/src/otel/index.ts b/packages/cozeloop-langchain/src/otel/index.ts index c7a5d73..097060d 100644 --- a/packages/cozeloop-langchain/src/otel/index.ts +++ b/packages/cozeloop-langchain/src/otel/index.ts @@ -3,3 +3,7 @@ export { CozeloopSpanProcessor } from './cozeloop-processor'; export { CozeloopSpanExporter } from './cozeloop-exporter'; export { BatchingQueue } from './batching-queue'; export type { CozeloopSpanExporterOptions } from './schema'; +export { + injectPropagationHeaders, + extractPropagationHeaders, +} from './propagation'; diff --git a/packages/cozeloop-langchain/src/otel/propagation.ts b/packages/cozeloop-langchain/src/otel/propagation.ts new file mode 100644 index 0000000..5bbb1ec --- /dev/null +++ b/packages/cozeloop-langchain/src/otel/propagation.ts @@ -0,0 +1,88 @@ +import { + type Context, + type TextMapGetter, + type TextMapSetter, + propagation, +} from '@opentelemetry/api'; + +enum CozeloopKey { + TRACEPARENT = 'X-Cozeloop-Traceparent', + TRACESTATE = 'X-Cozeloop-Tracestate', +} + +enum W3CKey { + TRACEPARENT = 'traceparent', + TRACESTATE = 'tracestate', +} + +const setter: TextMapSetter = { + set(carrier, key, value) { + if (!carrier || typeof carrier !== 'object') { + return; + } + + carrier[key] = value; + + // set additional k-v for cozeloop + switch (key) { + case W3CKey.TRACEPARENT: + carrier[CozeloopKey.TRACEPARENT] = value; + break; + case W3CKey.TRACESTATE: + carrier[CozeloopKey.TRACESTATE] = value; + break; + default: + break; + } + }, +}; + +const getter: TextMapGetter = { + get(carrier, key) { + if (!carrier || typeof carrier !== 'object') { + return undefined; + } + + switch (key) { + case W3CKey.TRACEPARENT: + return ( + carrier[CozeloopKey.TRACEPARENT] ?? + carrier[CozeloopKey.TRACEPARENT.toLowerCase()] ?? + carrier[W3CKey.TRACEPARENT] + ); + case W3CKey.TRACESTATE: + return ( + carrier[CozeloopKey.TRACESTATE] ?? + carrier[CozeloopKey.TRACESTATE.toLowerCase()] ?? + carrier[W3CKey.TRACESTATE] + ); + default: + return carrier[`${key}`]; + } + }, + keys(carrier) { + if (!carrier || typeof carrier !== 'object') { + return []; + } + + return Object.keys(carrier); + }, +}; + +/** + * Inject context into a carrier to be propagated inter-process + * @param context {@link Context} + * @param carrier any, such as http headers + */ +export function injectPropagationHeaders(context: Context, carrier: T) { + propagation.inject(context, carrier, setter); +} + +/** + * Extract context from a carrier + * @param context {@link Context} + * @param carrier any, such as http headers + */ +export function extractPropagationHeaders(context: Context, carrier: T) { + return propagation.extract(context, carrier, getter); +} diff --git a/packages/cozeloop-langchain/src/otel/schema.ts b/packages/cozeloop-langchain/src/otel/schema.ts index 2daab57..5d536fb 100644 --- a/packages/cozeloop-langchain/src/otel/schema.ts +++ b/packages/cozeloop-langchain/src/otel/schema.ts @@ -20,7 +20,10 @@ export const CozeloopSpanExporterOptionsSchema = z.object({ 'COZELOOP_WORKSPACE_ID', ), }), - /** Endpoint to export traces, use process.env.COZELOOP_OTLP_ENDPOINT when unprovided */ + /** + * Endpoint to export traces, use process.env.COZELOOP_OTLP_ENDPOINT when unprovided + * @default 'https://api.coze.cn/v1/loop/opentelemetry/v1/traces' + */ traceEndpoint: z .string() .prefault(process.env.COZELOOP_OTLP_ENDPOINT || DEFAULT_TRACE_ENDPOINT), From a6b75ef28d2b8a5ef18f0ed964e3b58d49198334 Mon Sep 17 00:00:00 2001 From: qihai Date: Thu, 3 Jul 2025 20:14:13 +0800 Subject: [PATCH 08/13] feat(cozeloop-langchain): increase ut cov --- .../__tests__/__mock__/custom-chat-model.ts | 73 ++++++++++++++ .../__tests__/__mock__/custom-llm-graph.ts | 35 +++++++ .../__tests__/__mock__/custom-model.ts | 1 - .../__tests__/__mock__/fan-in-out.ts | 37 ++++++++ .../__tests__/__mock__/index.ts | 3 + .../__tests__/__mock__/trace.ts | 18 ++++ .../callback/callback-handler.test.ts | 65 ++++++++++--- .../__tests__/callback/utils/message.test.ts | 95 +++++++++++++++++++ .../__tests__/otel/propagation.test.ts | 73 ++++++++++++++ .../__tests__/otel/sdk.test.ts | 74 +++++++++++++++ .../src/callbacks/callback-handler.ts | 5 +- .../src/callbacks/utils/message.ts | 16 +++- 12 files changed, 476 insertions(+), 19 deletions(-) create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/custom-chat-model.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/custom-llm-graph.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/fan-in-out.ts create mode 100644 packages/cozeloop-langchain/__tests__/__mock__/trace.ts create mode 100644 packages/cozeloop-langchain/__tests__/otel/propagation.test.ts create mode 100644 packages/cozeloop-langchain/__tests__/otel/sdk.test.ts diff --git a/packages/cozeloop-langchain/__tests__/__mock__/custom-chat-model.ts b/packages/cozeloop-langchain/__tests__/__mock__/custom-chat-model.ts new file mode 100644 index 0000000..7e55ec3 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/custom-chat-model.ts @@ -0,0 +1,73 @@ +import { setTimeout } from 'node:timers/promises'; + +import { type Runnable } from '@langchain/core/runnables'; +import { ChatGenerationChunk } from '@langchain/core/outputs'; +import { AIMessageChunk, type BaseMessage } from '@langchain/core/messages'; +import { + type BaseChatModelCallOptions, + type BindToolsInput, + SimpleChatModel, +} from '@langchain/core/language_models/chat_models'; +import { type BaseLanguageModelInput } from '@langchain/core/language_models/base'; +import { type CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager'; + +export class CustomChatModel extends SimpleChatModel { + readonly delay = 10; + + _llmType() { + return 'custom'; + } + + bindTools( + tools: BindToolsInput[], + kwargs?: Partial | undefined, + ): Runnable< + BaseLanguageModelInput, + AIMessageChunk, + BaseChatModelCallOptions + > { + return this; + } + + async _call( + messages: BaseMessage[], + options: this['ParsedCallOptions'], + runManager?: CallbackManagerForLLMRun, + ): Promise { + if (!messages.length) { + throw new Error('No messages provided.'); + } + // Pass `runManager?.getChild()` when invoking internal runnables to enable tracing + // await subRunnable.invoke(params, runManager?.getChild()); + if (typeof messages[0].content !== 'string') { + throw new Error('Multimodal messages are not supported.'); + } + return await setTimeout(this.delay, `[MOCK] ${JSON.stringify(messages)}`); + } + + async *_streamResponseChunks( + messages: BaseMessage[], + options: this['ParsedCallOptions'], + runManager?: CallbackManagerForLLMRun, + ): AsyncGenerator { + if (!messages.length) { + throw new Error('No messages provided.'); + } + if (typeof messages[0].content !== 'string') { + throw new Error('Multimodal messages are not supported.'); + } + // Pass `runManager?.getChild()` when invoking internal runnables to enable tracing + // await subRunnable.invoke(params, runManager?.getChild()); + const fullText = messages.map(it => it.content || '').join(','); + for (const letter of fullText.split('')) { + yield new ChatGenerationChunk({ + message: new AIMessageChunk({ + content: letter, + }), + text: letter, + }); + // Trigger the appropriate callback for new chunks + await runManager?.handleLLMNewToken(letter); + } + } +} diff --git a/packages/cozeloop-langchain/__tests__/__mock__/custom-llm-graph.ts b/packages/cozeloop-langchain/__tests__/__mock__/custom-llm-graph.ts new file mode 100644 index 0000000..cb62fb3 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/custom-llm-graph.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; +import { createReactAgent } from '@langchain/langgraph/prebuilt'; +import { tool } from '@langchain/core/tools'; + +import { CustomChatModel } from './custom-chat-model'; + +const model = new CustomChatModel({}); + +const getWeather = tool( + input => { + if (input.location === 'sf') { + return "It's always sunny in sf"; + } else { + return 'It might be cloudy in nyc'; + } + }, + { + name: 'get_weather', + description: 'Call to get the current weather.', + schema: z.object({ + location: z + .enum(['sf', 'nyc']) + .describe('Location to get the weather for.'), + }), + }, +); + +// We can add our system prompt here +const prompt = 'Respond in Italian'; + +export const customAgent = createReactAgent({ + llm: model, + tools: [getWeather], + stateModifier: prompt, +}); diff --git a/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts b/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts index 6366bdb..559f7c2 100644 --- a/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts +++ b/packages/cozeloop-langchain/__tests__/__mock__/custom-model.ts @@ -6,7 +6,6 @@ import type { CallbackManagerForLLMRun } from '@langchain/core/callbacks/manager export class CustomLLM extends LLM { readonly delay = 10; - readonly chunkSize = 2; _llmType() { return 'custom'; diff --git a/packages/cozeloop-langchain/__tests__/__mock__/fan-in-out.ts b/packages/cozeloop-langchain/__tests__/__mock__/fan-in-out.ts new file mode 100644 index 0000000..6356ff7 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/fan-in-out.ts @@ -0,0 +1,37 @@ +import { END, START, StateGraph, Annotation } from '@langchain/langgraph'; + +// see https://langchain-ai.github.io/langgraphjs/how-tos/branching/#fan-out-fan-in + +const StateAnnotation = Annotation.Root({ + aggregate: Annotation({ + reducer: (x, y) => x.concat(y), + }), +}); + +// Create the graph +const nodeA = (state: typeof StateAnnotation.State) => ({ + aggregate: ["I'm A"], +}); +const nodeB = (state: typeof StateAnnotation.State) => ({ + aggregate: ["I'm B"], +}); +const nodeC = (state: typeof StateAnnotation.State) => ({ + aggregate: ["I'm C"], +}); +const nodeD = (state: typeof StateAnnotation.State) => ({ + aggregate: ["I'm D"], +}); + +const builder = new StateGraph(StateAnnotation) + .addNode('a', nodeA) + .addEdge(START, 'a') + .addNode('b', nodeB) + .addNode('c', nodeC) + .addNode('d', nodeD) + .addEdge('a', 'b') + .addEdge('a', 'c') + .addEdge('b', 'd') + .addEdge('c', 'd') + .addEdge('d', END); + +export const fanGraph = builder.compile(); diff --git a/packages/cozeloop-langchain/__tests__/__mock__/index.ts b/packages/cozeloop-langchain/__tests__/__mock__/index.ts index 8d90630..70a9972 100644 --- a/packages/cozeloop-langchain/__tests__/__mock__/index.ts +++ b/packages/cozeloop-langchain/__tests__/__mock__/index.ts @@ -1,4 +1,7 @@ export { CustomLLM } from './custom-model'; export { CustomRetriever } from './custom-retriever'; export { reactAgentExecutor } from './react-agent'; +export { customAgent } from './custom-llm-graph'; export { graphAgent } from './graph-agent'; +export { fanGraph } from './fan-in-out'; +export { setupTraceMock } from './trace'; diff --git a/packages/cozeloop-langchain/__tests__/__mock__/trace.ts b/packages/cozeloop-langchain/__tests__/__mock__/trace.ts new file mode 100644 index 0000000..ddf6578 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/__mock__/trace.ts @@ -0,0 +1,18 @@ +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; + +export function setupTraceMock() { + // skip mock server when set COZELOOP_E2E = '1' + const isE2E = process.env.COZELOOP_E2E === '1'; + const server = setupServer( + http.post(/\/v1\/loop\/opentelemetry\/v1\/traces/i, () => + HttpResponse.json({ code: 0 }), + ), + ); + + return { + start: () => isE2E || server.listen({ onUnhandledRequest: 'bypass' }), + close: () => isE2E || server.close(), + reset: () => isE2E || server.resetHandlers(), + }; +} diff --git a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts index a6fb7a4..bd47d8d 100644 --- a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts @@ -9,12 +9,20 @@ import { CustomRetriever, reactAgentExecutor, graphAgent, + fanGraph, + setupTraceMock, + customAgent, } from '../__mock__'; const makeCallback = (input?: CozeloopCallbackHandlerInput) => new CozeloopCallbackHandler({ ...input }); describe('Callback with langchain', () => { + const httpMock = setupTraceMock(); + beforeAll(() => httpMock.start()); + afterAll(() => httpMock.close()); + afterEach(() => httpMock.reset()); + it('🧪 invoke model', async () => { const prompt = ChatPromptTemplate.fromTemplate('What is 1 + {number}?'); const model = new CustomLLM({}); @@ -52,7 +60,20 @@ describe('Callback with langchain', () => { await callback.flush(); }); - it('🧪 react agent', async () => { + it('🧪 retriever', async () => { + const callback = makeCallback(); + const retriever = new CustomRetriever(); + + const resp = await retriever.invoke('苹果派做法', { + callbacks: [callback], + runName: '🧑‍🍳 烹饪大家', + }); + expect(resp.length).toBeGreaterThan(1); + + await callback.flush(); + }); + + it.skip('🧪 react agent', async () => { const callback = makeCallback(); const resp = await reactAgentExecutor.invoke( { input: '翻译「苹果」到英文' }, @@ -63,23 +84,45 @@ describe('Callback with langchain', () => { await callback.flush(); }); +}); - it('🧪 retriever', async () => { +describe('Callback with langgraph', () => { + const httpMock = setupTraceMock(); + beforeAll(() => httpMock.start()); + afterAll(() => httpMock.close()); + afterEach(() => httpMock.reset()); + + it('🧪 simple graph', async () => { const callback = makeCallback(); - const retriever = new CustomRetriever(); + const resp = await fanGraph.invoke( + { aggregate: [] }, + { callbacks: [callback] }, + ); - const resp = await retriever.invoke('苹果派做法', { - callbacks: [callback], - runName: '🧑‍🍳 烹饪大家', - }); - expect(resp.length).toBeGreaterThan(1); + expect(resp.aggregate.length).toBeGreaterThan(0); await callback.flush(); }); -}); -describe('Callback with langgraph', () => { - it('🧪 graph agent', async () => { + it('🧪 custom llm agent', async () => { + const callback = makeCallback(); + const resp = await customAgent.invoke( + { + messages: [ + { + role: 'user', + content: 'what is the weather in sf', + }, + ], + }, + { callbacks: [callback] }, + ); + expect(resp.messages.length).toBeGreaterThan(1); + + await callback.flush(); + }); + + it.skip('🧪 graph agent', async () => { const callback = makeCallback(); const resp = await graphAgent.invoke( { diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts index f4f48af..be9490e 100644 --- a/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts @@ -3,6 +3,7 @@ import { HumanMessage, type MessageContentComplex, } from '@langchain/core/messages'; + import { parseRawMessage, parseBaseMessage, @@ -62,6 +63,11 @@ describe('parseBaseMessage', () => { }); describe('parseBaseMessages', () => { + it('🧪 should return undefined on empty/undefined', () => { + expect(parseBaseMessages()).toBeUndefined(); + expect(parseBaseMessages([])).toBeUndefined(); + }); + it('🧪 should parse an array of BaseMessage correctly', () => { const baseMessages = [ new HumanMessage('Hello, world!'), @@ -83,9 +89,98 @@ describe('parseBaseMessages', () => { }, ]); }); + + it('🧪 should parse message parts correctly', () => { + const parsed = parseBaseMessages([ + [ + new HumanMessage({ + content: [ + { type: 'text', text: '123' }, + { + type: 'image', + image_url: { + name: 'img1', + url: 'https://fake.cdn.com/img1.png', + }, + }, + { type: 'image_url', image_url: 'https://fake.cdn.com/img2.png' }, + ], + }), + new HumanMessage({ + content: [ + { type: 'text', text: '123' }, + { type: 'file', file_url: 'https://fake.cdn.com/file1.pdf' }, + { + type: 'file_url', + file_url: { + name: 'file2', + url: 'https://fake.cdn.com/file2.pdf', + }, + }, + ], + }), + ], + ]); + + expect(parsed?.length).toBe(2); + expect(parsed?.[0].parts).toMatchObject([ + { type: 'text', text: '123' }, + { + type: 'image_url', + image_url: { name: 'img1', url: 'https://fake.cdn.com/img1.png' }, + }, + { + type: 'image_url', + image_url: { url: 'https://fake.cdn.com/img2.png' }, + }, + ]); + expect(parsed?.[1].parts).toMatchObject([ + { type: 'text', text: '123' }, + { type: 'file_url', file_url: { url: 'https://fake.cdn.com/file1.pdf' } }, + { + type: 'file_url', + file_url: { name: 'file2', url: 'https://fake.cdn.com/file2.pdf' }, + }, + ]); + }); + + it('🧪 should parse tool calls correctly', () => { + const parsed = parseBaseMessages([ + [ + new AIMessage({ + content: '', + tool_calls: [ + { + id: 'tool-call-123', + name: 'getWeather', + args: { location: 'Shanghai' }, + }, + ], + }), + new AIMessage({ content: 'ai' }), + ], + ]); + + expect(parsed?.[0].tool_calls).toMatchObject([ + { + id: 'tool-call-123', + type: 'function', + function: { + name: 'getWeather', + arguments: JSON.stringify({ location: 'Shanghai' }), + }, + }, + ]); + expect(parsed?.[1].tool_calls).toBeUndefined(); + }); }); describe('parseLLMPrompts', () => { + it('🧪 should parse undefined or empty correctly', () => { + expect(parseLLMPrompts()).toBeUndefined(); + expect(parseLLMPrompts([])).toBeUndefined(); + }); + it('🧪 should parse an array of strings correctly', () => { const prompts = ['Hello, world!', 'Hello, universe!']; expect(parseLLMPrompts(prompts)).toEqual(prompts); diff --git a/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts b/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts new file mode 100644 index 0000000..da946a1 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts @@ -0,0 +1,73 @@ +import { type ReadableSpan } from '@opentelemetry/sdk-trace-base'; +import { TraceState } from '@opentelemetry/core'; +import { context, trace } from '@opentelemetry/api'; + +import { + extractPropagationHeaders, + injectPropagationHeaders, +} from '@cozeloop/langchain/otel'; + +const tracer = trace.getTracer('test'); + +describe('injectPropagationHeaders', () => { + it('🧪 should inject propagation headers into the carrier', () => { + const carrier = {}; + const parent = tracer.startSpan('root'); + const ctx = trace.setSpan(context.active(), parent); + const spanCtx = trace.getSpanContext(ctx); + if (spanCtx) { + spanCtx.traceState = new TraceState().set('q', '1'); + } + + injectPropagationHeaders(ctx, carrier); + + parent.end(); + + expect(carrier).toHaveProperty('traceparent'); + expect(carrier).toHaveProperty('tracestate'); + expect(carrier).toHaveProperty('X-Cozeloop-Traceparent'); + expect(carrier).toHaveProperty('X-Cozeloop-Tracestate'); + }); +}); + +describe('extractPropagationHeaders', () => { + it('🧪 should extract propagation headers from the carrier', () => { + const traceId = '4bf92f3577b34da6a3ce929d0e0e4736'; + const spanId = '00f067aa0ba902b7'; + const traceparent = `00-${traceId}-${spanId}-01`; + const carrier = { + traceparent, + tracestate: 'w3c=1', + }; + + const ctx = extractPropagationHeaders(context.active(), carrier); + const span = tracer.startSpan('test', undefined, ctx); + const spanCtx = span.spanContext(); + + expect((span as unknown as ReadableSpan).parentSpanId).toEqual(spanId); + expect(spanCtx.traceId).toEqual(traceId); + expect(spanCtx.traceState?.get('w3c')).toEqual('1'); + }); + + it('🧪 should extract propagation headers with priority', () => { + const traceId1 = '4bf92f3577b34da6a3ce929d0e0e4736'; + const traceId2 = '4bf92f3577b34da6a3ce929d0e0effff'; + const spanId1 = '00f067aa0ba902b8'; + const spanId2 = '00f067aa0ba9ffff'; + const carrier = { + traceparent: `00-${traceId1}-${spanId1}-01`, + tracestate: 'w3c=1', + 'X-Cozeloop-Traceparent': `00-${traceId2}-${spanId2}-01`, + 'X-Cozeloop-Tracestate': 'cozeloop=1', + }; + + const ctx = extractPropagationHeaders(context.active(), carrier); + const span = tracer.startSpan('test', undefined, ctx); + const spanCtx = span.spanContext(); + + expect((span as unknown as ReadableSpan).parentSpanId).toEqual(spanId2); + expect(spanCtx.traceId).toEqual(traceId2); + expect(spanCtx.traceState?.get('w3c')).toBeUndefined(); + expect(spanCtx.traceState?.get('cozeloop')).toEqual('1'); + }); +}); diff --git a/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts b/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts new file mode 100644 index 0000000..ac495e5 --- /dev/null +++ b/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts @@ -0,0 +1,74 @@ +import { test, describe, expect, vi } from 'vitest'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { + type CozeloopSpanExporterOptions, + CozeloopSpanProcessor, + OTelNodeSDK, +} from '@cozeloop/langchain/otel'; + +describe('OTelNodeSDK', () => { + test('addExporter should call cozeloopProcessor.addExporter', () => { + const addExporterMock = vi.spyOn( + CozeloopSpanProcessor.prototype, + 'addExporter', + ); + const name = 'test-exporter'; + const options: CozeloopSpanExporterOptions = { + workspaceId: '10086', + token: 'pat_xxx', + }; + + OTelNodeSDK.addExporter(name, options); + + expect(addExporterMock).toHaveBeenCalledWith(name, options); + }); + + // 添加其他测试用例 + test('removeExporter should call cozeloopProcessor.removeExporter', async () => { + const removeExporterMock = vi.spyOn( + CozeloopSpanProcessor.prototype, + 'removeExporter', + ); + const name = 'test-exporter'; + + await OTelNodeSDK.removeExporter(name); + + expect(removeExporterMock).toHaveBeenCalledWith(name); + }); + + test('flushExporter should call cozeloopProcessor.flushExporter', async () => { + const flushExporterMock = vi.spyOn( + CozeloopSpanProcessor.prototype, + 'flushExporter', + ); + const name = 'test-exporter'; + + await OTelNodeSDK.flushExporter(name); + + expect(flushExporterMock).toHaveBeenCalledWith(name); + }); + + test('forceFlush should call cozeloopProcessor.forceFlush', async () => { + const forceFlushMock = vi.spyOn( + CozeloopSpanProcessor.prototype, + 'forceFlush', + ); + + await OTelNodeSDK.forceFlush(); + + expect(forceFlushMock).toHaveBeenCalled(); + }); + + test('shutdown should call cozeloopProcessor.shutdown and sdk.shutdown', async () => { + const cozeloopShutdownMock = vi.spyOn( + CozeloopSpanProcessor.prototype, + 'shutdown', + ); + const sdkShutdownMock = vi.spyOn(NodeSDK.prototype, 'shutdown'); + + await OTelNodeSDK.shutdown(); + + expect(cozeloopShutdownMock).toHaveBeenCalled(); + expect(sdkShutdownMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts index e4dbffa..b51a403 100644 --- a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -524,9 +524,10 @@ export class CozeloopCallbackHandler ? extractPropagationHeaders(context.active(), this._propagationHeaders) : context.active(); + // update propagation headers + injectPropagationHeaders(currentContext, this._w3cPropagationHeaders); + context.with(currentContext, () => { - injectPropagationHeaders(currentContext, this._w3cPropagationHeaders); - console.info(this._w3cPropagationHeaders); const span = this._tracer.startSpan(name); // span.setAttribute(CozeloopAttr.WORKSPACE_ID, this._workspaceId || ''); diff --git a/packages/cozeloop-langchain/src/callbacks/utils/message.ts b/packages/cozeloop-langchain/src/callbacks/utils/message.ts index acf6fd8..1e3081f 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/message.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/message.ts @@ -66,10 +66,12 @@ function parseContent(message?: BaseMessage) { return undefined; } +// eslint-disable-next-line complexity -- skip function parsePart(complex: MessageContentComplex): LoopMessageContentPart { switch (complex.type) { case 'text': return { type: 'text', text: stringifyVal(complex.text) }; + case 'image': case 'image_url': return { type: 'image_url', @@ -82,13 +84,17 @@ function parsePart(complex: MessageContentComplex): LoopMessageContentPart { detail: complex.image_url?.url, }, }; + case 'file': case 'file_url': return { type: 'file_url', - file_url: { - name: complex.file_url?.name || complex.name, - url: complex.file_url?.url || complex.url, - }, + file_url: + typeof complex?.file_url === 'string' + ? { url: complex.file_url } + : { + name: complex.file_url?.name || complex.name, + url: complex.file_url?.url || complex.url, + }, }; default: return { type: 'text', text: stringifyVal(complex) }; @@ -154,7 +160,7 @@ export function parseBaseMessage(message?: BaseMessage): LoopMessage { } export function parseBaseMessages(messages?: BaseMessage[][]) { - if (!messages?.[0].length) { + if (!messages?.length) { return undefined; } From f6376222eafdc2ce58226eba85b23c5e8d562c89 Mon Sep 17 00:00:00 2001 From: qihai Date: Thu, 3 Jul 2025 20:21:17 +0800 Subject: [PATCH 09/13] feat: ut cov threshold to 80% --- codecov.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/codecov.yml b/codecov.yml index acb5a62..308101a 100644 --- a/codecov.yml +++ b/codecov.yml @@ -10,11 +10,11 @@ coverage: status: project: default: - target: 90% + target: 80% threshold: 1% patch: default: - target: 90% + target: 80% threshold: 5% comment: @@ -29,10 +29,10 @@ component_management: default_rules: # default rules that will be inherited by all components statuses: - type: project - target: 90% + target: 80% threshold: 1% - type: patch - target: 90% + target: 80% threshold: 5% individual_components: @@ -40,3 +40,8 @@ component_management: name: '@cozeloop/ai' paths: - packages/cozeloop-ai/** + + - component_id: cozeloop-langchain + name: '@cozeloop/langchain' + paths: + - packages/cozeloop-langchain/** From 4ff880ab99c358523f284ba40f99f3cc1abd18e8 Mon Sep 17 00:00:00 2001 From: qihai Date: Thu, 3 Jul 2025 20:32:57 +0800 Subject: [PATCH 10/13] feat: add license header --- packages/ci-tools/src/index.ts | 2 ++ packages/ci-tools/src/lark/index.ts | 2 ++ packages/ci-tools/src/lark/schema.ts | 2 ++ packages/ci-tools/src/lark/send-message.ts | 2 ++ packages/ci-tools/src/lark/sync-issue.ts | 2 ++ .../__tests__/callback/callback-handler.test.ts | 2 ++ .../cozeloop-langchain/__tests__/callback/utils/chain.test.ts | 2 ++ .../cozeloop-langchain/__tests__/callback/utils/common.test.ts | 2 ++ .../__tests__/callback/utils/message.test.ts | 2 ++ .../cozeloop-langchain/__tests__/callback/utils/model.test.ts | 3 +++ .../cozeloop-langchain/__tests__/otel/batching-queue.test.ts | 2 ++ packages/cozeloop-langchain/__tests__/otel/processor.test.ts | 2 ++ packages/cozeloop-langchain/__tests__/otel/propagation.test.ts | 2 ++ packages/cozeloop-langchain/__tests__/otel/sdk.test.ts | 3 +++ packages/cozeloop-langchain/src/callbacks/callback-handler.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/constants.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/index.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/utils/chain.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/utils/common.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/utils/index.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/utils/message.ts | 2 ++ packages/cozeloop-langchain/src/callbacks/utils/model.ts | 2 ++ packages/cozeloop-langchain/src/index.ts | 2 ++ packages/cozeloop-langchain/src/otel/batching-queue.ts | 2 ++ packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts | 2 ++ packages/cozeloop-langchain/src/otel/cozeloop-processor.ts | 2 ++ packages/cozeloop-langchain/src/otel/index.ts | 2 ++ packages/cozeloop-langchain/src/otel/propagation.ts | 2 ++ packages/cozeloop-langchain/src/otel/schema.ts | 2 ++ packages/cozeloop-langchain/src/otel/sdk.ts | 2 ++ 30 files changed, 62 insertions(+) diff --git a/packages/ci-tools/src/index.ts b/packages/ci-tools/src/index.ts index ed40217..1ad5ad8 100644 --- a/packages/ci-tools/src/index.ts +++ b/packages/ci-tools/src/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { Command } from 'commander'; import { applyLarkCommand } from './lark'; diff --git a/packages/ci-tools/src/lark/index.ts b/packages/ci-tools/src/lark/index.ts index b77cbd0..d3b8260 100644 --- a/packages/ci-tools/src/lark/index.ts +++ b/packages/ci-tools/src/lark/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { Command, Option } from 'commander'; import { applySyncIssue } from './sync-issue'; diff --git a/packages/ci-tools/src/lark/schema.ts b/packages/ci-tools/src/lark/schema.ts index a8235b1..ee90f39 100644 --- a/packages/ci-tools/src/lark/schema.ts +++ b/packages/ci-tools/src/lark/schema.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { z } from 'zod/v4'; import { AppType, Domain } from '@larksuiteoapi/node-sdk'; diff --git a/packages/ci-tools/src/lark/send-message.ts b/packages/ci-tools/src/lark/send-message.ts index 75dd4df..3ef3278 100644 --- a/packages/ci-tools/src/lark/send-message.ts +++ b/packages/ci-tools/src/lark/send-message.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { Command } from 'commander'; import { Client } from '@larksuiteoapi/node-sdk'; diff --git a/packages/ci-tools/src/lark/sync-issue.ts b/packages/ci-tools/src/lark/sync-issue.ts index 2c752ca..c586152 100644 --- a/packages/ci-tools/src/lark/sync-issue.ts +++ b/packages/ci-tools/src/lark/sync-issue.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { Command } from 'commander'; import { Client } from '@larksuiteoapi/node-sdk'; diff --git a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts index bd47d8d..aa59787 100644 --- a/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/callback-handler.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { ChatPromptTemplate } from '@langchain/core/prompts'; import { diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts index 9bbe309..eb6b3da 100644 --- a/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/chain.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { guessChainInput, guessChainOutput, diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts index d72e224..41b3ac5 100644 --- a/packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/common.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { generateUUID, stringifyVal, diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts index be9490e..d272a74 100644 --- a/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/message.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { AIMessage, HumanMessage, diff --git a/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts b/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts index bc14f17..1fe64a2 100644 --- a/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts +++ b/packages/cozeloop-langchain/__tests__/callback/utils/model.test.ts @@ -1,4 +1,7 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type Serialized } from '@langchain/core/load/serializable'; + import { guessModelProvider, extractLLMAttributes, diff --git a/packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts b/packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts index 56056ba..eb437ce 100644 --- a/packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts +++ b/packages/cozeloop-langchain/__tests__/otel/batching-queue.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { BatchingQueue } from '@cozeloop/langchain/otel'; describe('BatchingQueue', () => { diff --git a/packages/cozeloop-langchain/__tests__/otel/processor.test.ts b/packages/cozeloop-langchain/__tests__/otel/processor.test.ts index 079e00a..5b6de9a 100644 --- a/packages/cozeloop-langchain/__tests__/otel/processor.test.ts +++ b/packages/cozeloop-langchain/__tests__/otel/processor.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { CozeloopSpanExporter, CozeloopSpanProcessor, diff --git a/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts b/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts index da946a1..26c05cb 100644 --- a/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts +++ b/packages/cozeloop-langchain/__tests__/otel/propagation.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { TraceState } from '@opentelemetry/core'; import { context, trace } from '@opentelemetry/api'; diff --git a/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts b/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts index ac495e5..a43e8c8 100644 --- a/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts +++ b/packages/cozeloop-langchain/__tests__/otel/sdk.test.ts @@ -1,5 +1,8 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { test, describe, expect, vi } from 'vitest'; import { NodeSDK } from '@opentelemetry/sdk-node'; + import { type CozeloopSpanExporterOptions, CozeloopSpanProcessor, diff --git a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts index b51a403..71f9884 100644 --- a/packages/cozeloop-langchain/src/callbacks/callback-handler.ts +++ b/packages/cozeloop-langchain/src/callbacks/callback-handler.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT /* eslint-disable @typescript-eslint/no-explicit-any -- callback handler params */ /* eslint-disable max-params -- callback handler methods */ import { diff --git a/packages/cozeloop-langchain/src/callbacks/constants.ts b/packages/cozeloop-langchain/src/callbacks/constants.ts index c4f96b4..82e70e4 100644 --- a/packages/cozeloop-langchain/src/callbacks/constants.ts +++ b/packages/cozeloop-langchain/src/callbacks/constants.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT export enum CozeloopAttr { WORKSPACE_ID = 'cozeloop.workspace_id', SPAN_TYPE = 'cozeloop.span_type', diff --git a/packages/cozeloop-langchain/src/callbacks/index.ts b/packages/cozeloop-langchain/src/callbacks/index.ts index 552543d..7400bda 100644 --- a/packages/cozeloop-langchain/src/callbacks/index.ts +++ b/packages/cozeloop-langchain/src/callbacks/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT export { CozeloopCallbackHandler, type CozeloopCallbackHandlerInput, diff --git a/packages/cozeloop-langchain/src/callbacks/utils/chain.ts b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts index 3365066..63873b3 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/chain.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/chain.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type ChainValues } from '@langchain/core/utils/types'; export function guessChainInput(inputs?: ChainValues) { diff --git a/packages/cozeloop-langchain/src/callbacks/utils/common.ts b/packages/cozeloop-langchain/src/callbacks/utils/common.ts index 56a58ac..c07448e 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/common.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/common.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { randomBytes } from 'node:crypto'; export function generateUUID(): string { diff --git a/packages/cozeloop-langchain/src/callbacks/utils/index.ts b/packages/cozeloop-langchain/src/callbacks/utils/index.ts index b7fb2c5..7d20237 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/index.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT export { guessChainInput, guessChainOutput } from './chain'; export { generateUUID, stringifyVal } from './common'; export { parseBaseMessages, parseRawMessage } from './message'; diff --git a/packages/cozeloop-langchain/src/callbacks/utils/message.ts b/packages/cozeloop-langchain/src/callbacks/utils/message.ts index 1e3081f..5b2e027 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/message.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/message.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type Generation } from '@langchain/core/outputs'; import { BaseMessage, diff --git a/packages/cozeloop-langchain/src/callbacks/utils/model.ts b/packages/cozeloop-langchain/src/callbacks/utils/model.ts index 77055e7..47bfc7e 100644 --- a/packages/cozeloop-langchain/src/callbacks/utils/model.ts +++ b/packages/cozeloop-langchain/src/callbacks/utils/model.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type LLMResult } from '@langchain/core/outputs'; import { type Serialized } from '@langchain/core/load/serializable'; diff --git a/packages/cozeloop-langchain/src/index.ts b/packages/cozeloop-langchain/src/index.ts index b53916d..381def7 100644 --- a/packages/cozeloop-langchain/src/index.ts +++ b/packages/cozeloop-langchain/src/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT export { CozeloopCallbackHandler } from './callbacks'; export type { CozeloopCallbackHandlerInput } from './callbacks'; export type { CozeloopSpanExporterOptions } from './otel'; diff --git a/packages/cozeloop-langchain/src/otel/batching-queue.ts b/packages/cozeloop-langchain/src/otel/batching-queue.ts index 5f56f2e..d17fe6f 100644 --- a/packages/cozeloop-langchain/src/otel/batching-queue.ts +++ b/packages/cozeloop-langchain/src/otel/batching-queue.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT /** * BatchingQueue is a queue class that supports batching and delayed dequeuing. * diff --git a/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts b/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts index 0bab9d2..4e0b57c 100644 --- a/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts +++ b/packages/cozeloop-langchain/src/otel/cozeloop-exporter.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import type { ReadableSpan } from '@opentelemetry/sdk-trace-base'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import { globalErrorHandler } from '@opentelemetry/core'; diff --git a/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts b/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts index 1995457..3768d9a 100644 --- a/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts +++ b/packages/cozeloop-langchain/src/otel/cozeloop-processor.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type ReadableSpan, type Span, diff --git a/packages/cozeloop-langchain/src/otel/index.ts b/packages/cozeloop-langchain/src/otel/index.ts index 097060d..cda269b 100644 --- a/packages/cozeloop-langchain/src/otel/index.ts +++ b/packages/cozeloop-langchain/src/otel/index.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT export { OTelNodeSDK } from './sdk'; export { CozeloopSpanProcessor } from './cozeloop-processor'; export { CozeloopSpanExporter } from './cozeloop-exporter'; diff --git a/packages/cozeloop-langchain/src/otel/propagation.ts b/packages/cozeloop-langchain/src/otel/propagation.ts index 5bbb1ec..5985eb7 100644 --- a/packages/cozeloop-langchain/src/otel/propagation.ts +++ b/packages/cozeloop-langchain/src/otel/propagation.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { type Context, type TextMapGetter, diff --git a/packages/cozeloop-langchain/src/otel/schema.ts b/packages/cozeloop-langchain/src/otel/schema.ts index 5d536fb..53b05e5 100644 --- a/packages/cozeloop-langchain/src/otel/schema.ts +++ b/packages/cozeloop-langchain/src/otel/schema.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { z } from 'zod/v4'; function formatPropertyUnprovidedError(propName: string, envKey: string) { diff --git a/packages/cozeloop-langchain/src/otel/sdk.ts b/packages/cozeloop-langchain/src/otel/sdk.ts index 656e777..d3d7575 100644 --- a/packages/cozeloop-langchain/src/otel/sdk.ts +++ b/packages/cozeloop-langchain/src/otel/sdk.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates +// SPDX-License-Identifier: MIT import { NodeSDK } from '@opentelemetry/sdk-node'; import { type CozeloopSpanExporterOptions } from './schema'; From 6b693f107de1ed12d97b1f611cb311f01f6364bb Mon Sep 17 00:00:00 2001 From: qihai Date: Fri, 4 Jul 2025 09:59:24 +0800 Subject: [PATCH 11/13] fix: failed ut --- packages/ci-tools/package.json | 4 ++-- packages/cozeloop-ai/__tests__/utils/common.test.ts | 2 +- packages/cozeloop-langchain/vitest.config.ts | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/ci-tools/package.json b/packages/ci-tools/package.json index 00b5ef3..84bf0be 100644 --- a/packages/ci-tools/package.json +++ b/packages/ci-tools/package.json @@ -30,8 +30,8 @@ "lint": "eslint ./ --cache", "prepublishOnly": "npm run build", "start": "vitest watch", - "test": "vitest run", - "test:cov": "vitest run --coverage", + "test": "vitest run --passWithNoTests", + "test:cov": "vitest run --passWithNoTests --coverage", "vitest": "vitest" }, "dependencies": { diff --git a/packages/cozeloop-ai/__tests__/utils/common.test.ts b/packages/cozeloop-ai/__tests__/utils/common.test.ts index 2c4295a..198ff24 100644 --- a/packages/cozeloop-ai/__tests__/utils/common.test.ts +++ b/packages/cozeloop-ai/__tests__/utils/common.test.ts @@ -155,7 +155,7 @@ describe('Test utils/common.ts', () => { it('should handle Date object', () => { const date = new Date('2023-05-20T12:00:00Z'); - expect(stringifyVal(date)).toBe(JSON.stringify(date)); + expect(stringifyVal(date)).toBe('2023-05-20T12:00:00Z'); }); it('should handle custom objects', () => { diff --git a/packages/cozeloop-langchain/vitest.config.ts b/packages/cozeloop-langchain/vitest.config.ts index 78e73bc..c56e0a7 100644 --- a/packages/cozeloop-langchain/vitest.config.ts +++ b/packages/cozeloop-langchain/vitest.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, env: { COZELOOP_VERSION: packageJson.version, + COZELOOP_WORKSPACE_ID: '7308703665823416358', + COZELOOP_API_TOKEN: 'pat_xxx', + GPT_OPEN_API_KEY: 'xxxx', }, }, }); From 25f7a49b69acdeeaa3c9043a533fd6361390fe8d Mon Sep 17 00:00:00 2001 From: qihai Date: Fri, 4 Jul 2025 10:04:41 +0800 Subject: [PATCH 12/13] fix: failed ut v2 --- packages/cozeloop-ai/__tests__/utils/common.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cozeloop-ai/__tests__/utils/common.test.ts b/packages/cozeloop-ai/__tests__/utils/common.test.ts index 198ff24..59acb49 100644 --- a/packages/cozeloop-ai/__tests__/utils/common.test.ts +++ b/packages/cozeloop-ai/__tests__/utils/common.test.ts @@ -155,7 +155,7 @@ describe('Test utils/common.ts', () => { it('should handle Date object', () => { const date = new Date('2023-05-20T12:00:00Z'); - expect(stringifyVal(date)).toBe('2023-05-20T12:00:00Z'); + expect(stringifyVal(date)).toBe(date.toISOString()); }); it('should handle custom objects', () => { From e14b35b3084f871a65f44340cfc7ad534ef91c26 Mon Sep 17 00:00:00 2001 From: qihai Date: Fri, 4 Jul 2025 15:14:09 +0800 Subject: [PATCH 13/13] fix(ci-tools): sync issue error msg --- packages/ci-tools/src/lark/sync-issue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ci-tools/src/lark/sync-issue.ts b/packages/ci-tools/src/lark/sync-issue.ts index c586152..2703def 100644 --- a/packages/ci-tools/src/lark/sync-issue.ts +++ b/packages/ci-tools/src/lark/sync-issue.ts @@ -91,7 +91,7 @@ async function syncIssue(this: Command) { if (resp.code === 0) { success++; } else { - errors.push(`Error send to ${it}, errMsg=${resp.msg}`); + errors.push(`Error send to ${type}#${id}, errMsg=${resp.msg}`); } };